From 5a4f4e2b2e144cddc91fd8545bc6ce7c24f46f49 Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Tue, 11 Aug 2026 16:31:59 -0600 Subject: [PATCH 01/17] Initial implementation of simulator --- .gitignore | 2 + Cargo.lock | 44 +- Cargo.toml | 6 + crates/lab-cli/Cargo.toml | 5 +- crates/lab-cli/src/main.rs | 37 +- crates/lab-cli/src/run.rs | 362 +---- crates/lab-cli/src/scene.rs | 126 ++ crates/lab-cli/src/simulate.rs | 140 ++ crates/lab-cli/src/workcell_run.rs | 669 ++------- crates/lab-cli/tests/project_workflow.rs | 162 +++ crates/lab-compiler/Cargo.toml | 1 + crates/lab-compiler/src/lib.rs | 2 +- crates/lab-compiler/src/planning/model.rs | 30 + crates/lab-package/src/manifest.rs | 38 + crates/lab-package/src/package.rs | 4 + crates/lab-runfmt/Cargo.toml | 20 + .../runfmt/mod.rs => lab-runfmt/src/lib.rs} | 120 +- crates/lab-runtime/Cargo.toml | 29 + crates/lab-runtime/src/clock.rs | 72 + crates/lab-runtime/src/durations.rs | 177 +++ crates/lab-runtime/src/events.rs | 163 +++ crates/lab-runtime/src/facility.rs | 348 +++++ crates/lab-runtime/src/ledger.rs | 130 ++ crates/lab-runtime/src/lib.rs | 33 + crates/lab-runtime/src/operator.rs | 50 + crates/lab-runtime/src/simulate.rs | 266 ++++ crates/lab-runtime/src/star.rs | 331 +++++ crates/lab-runtime/src/stations/mod.rs | 242 ++++ crates/lab-runtime/src/stations/sim.rs | 164 +++ crates/lab-runtime/src/testing.rs | 73 + crates/lab-runtime/src/trace.rs | 139 ++ crates/lab-runtime/src/workcell.rs | 781 +++++++++++ crates/lab-scene/Cargo.toml | 19 + crates/lab-scene/src/dims.rs | 97 ++ crates/lab-scene/src/gltf.rs | 374 +++++ crates/lab-scene/src/lib.rs | 22 + crates/lab-scene/src/scene.rs | 109 ++ crates/lab-scene/src/star.rs | 321 +++++ crates/lab-scene/src/usda.rs | 178 +++ crates/lab-scene/src/workcell.rs | 88 ++ docs/README.md | 3 + .../0034-the-simulator-is-an-interpreter.md | 50 + .../language/decisions/0035-facility-files.md | 52 + .../golden-gate/facilities/main-bench.toml | 34 + viewer/README.md | 38 + viewer/index.html | 72 + viewer/package-lock.json | 1204 +++++++++++++++++ viewer/package.json | 20 + viewer/src/main.ts | 534 ++++++++ viewer/tsconfig.json | 14 + 50 files changed, 7073 insertions(+), 922 deletions(-) create mode 100644 crates/lab-cli/src/scene.rs create mode 100644 crates/lab-cli/src/simulate.rs create mode 100644 crates/lab-runfmt/Cargo.toml rename crates/{lab-compiler/src/runfmt/mod.rs => lab-runfmt/src/lib.rs} (62%) create mode 100644 crates/lab-runtime/Cargo.toml create mode 100644 crates/lab-runtime/src/clock.rs create mode 100644 crates/lab-runtime/src/durations.rs create mode 100644 crates/lab-runtime/src/events.rs create mode 100644 crates/lab-runtime/src/facility.rs create mode 100644 crates/lab-runtime/src/ledger.rs create mode 100644 crates/lab-runtime/src/lib.rs create mode 100644 crates/lab-runtime/src/operator.rs create mode 100644 crates/lab-runtime/src/simulate.rs create mode 100644 crates/lab-runtime/src/star.rs create mode 100644 crates/lab-runtime/src/stations/mod.rs create mode 100644 crates/lab-runtime/src/stations/sim.rs create mode 100644 crates/lab-runtime/src/testing.rs create mode 100644 crates/lab-runtime/src/trace.rs create mode 100644 crates/lab-runtime/src/workcell.rs create mode 100644 crates/lab-scene/Cargo.toml create mode 100644 crates/lab-scene/src/dims.rs create mode 100644 crates/lab-scene/src/gltf.rs create mode 100644 crates/lab-scene/src/lib.rs create mode 100644 crates/lab-scene/src/scene.rs create mode 100644 crates/lab-scene/src/star.rs create mode 100644 crates/lab-scene/src/usda.rs create mode 100644 crates/lab-scene/src/workcell.rs create mode 100644 docs/language/decisions/0034-the-simulator-is-an-interpreter.md create mode 100644 docs/language/decisions/0035-facility-files.md create mode 100644 examples/golden-gate/facilities/main-bench.toml create mode 100644 viewer/README.md create mode 100644 viewer/index.html create mode 100644 viewer/package-lock.json create mode 100644 viewer/package.json create mode 100644 viewer/src/main.ts create mode 100644 viewer/tsconfig.json diff --git a/.gitignore b/.gitignore index 17c9f51..cf53b5b 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ __pycache__/ .mypy_cache/ .pytest_cache/ .ruff_cache/ +viewer/node_modules/ +viewer/dist/ diff --git a/Cargo.lock b/Cargo.lock index a3c66ae..1bfb49e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1605,11 +1605,12 @@ dependencies = [ "anyhow", "clap", "flate2", - "hamilton-star", "lab-compiler", - "lab-instruments", "lab-package", "lab-project", + "lab-runfmt", + "lab-runtime", + "lab-scene", "self-replace", "semver", "serde", @@ -1635,6 +1636,7 @@ dependencies = [ "hamilton-star", "lab-instruments", "lab-language", + "lab-runfmt", "opentrons-protocol", "pliron", "serde", @@ -1716,6 +1718,44 @@ dependencies = [ "toml", ] +[[package]] +name = "lab-runfmt" +version = "0.1.2" +dependencies = [ + "lab-instruments", + "serde", + "serde_json", + "tempfile", + "thiserror", +] + +[[package]] +name = "lab-runtime" +version = "0.1.2" +dependencies = [ + "anyhow", + "hamilton-star", + "lab-instruments", + "lab-runfmt", + "serde", + "serde_json", + "tempfile", + "thiserror", + "toml", +] + +[[package]] +name = "lab-scene" +version = "0.1.2" +dependencies = [ + "base64", + "lab-compiler", + "lab-runfmt", + "serde", + "serde_json", + "thiserror", +] + [[package]] name = "lab-sdk-python" version = "0.1.2" diff --git a/Cargo.toml b/Cargo.toml index c00a91c..6ba14eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,9 @@ members = [ "crates/lab-package", "crates/lab-project", "crates/lab-python", + "crates/lab-runfmt", + "crates/lab-runtime", + "crates/lab-scene", ] [workspace.package] @@ -72,6 +75,9 @@ lab-language-server = { path = "crates/lab-language-server", version = "0.1.2" } opentrons-protocol = "0.1.0" lab-package = { path = "crates/lab-package", version = "0.1.2" } lab-project = { path = "crates/lab-project", version = "0.1.2" } +lab-runfmt = { path = "crates/lab-runfmt", version = "0.1.2" } +lab-runtime = { path = "crates/lab-runtime", version = "0.1.2" } +lab-scene = { path = "crates/lab-scene", version = "0.1.2" } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/crates/lab-cli/Cargo.toml b/crates/lab-cli/Cargo.toml index be64a42..f4d5e28 100644 --- a/crates/lab-cli/Cargo.toml +++ b/crates/lab-cli/Cargo.toml @@ -16,10 +16,11 @@ anyhow.workspace = true clap.workspace = true flate2 = "1.1.9" lab-compiler.workspace = true +lab-runfmt.workspace = true # The runner needs the live USB transport the workspace dependency keeps # off by default. -hamilton-star = { workspace = true, features = ["usb"] } -lab-instruments.workspace = true +lab-runtime = { workspace = true, features = ["hardware"] } +lab-scene.workspace = true lab-package.workspace = true lab-project.workspace = true self-replace = "1.5.0" diff --git a/crates/lab-cli/src/main.rs b/crates/lab-cli/src/main.rs index f927670..5b034fb 100644 --- a/crates/lab-cli/src/main.rs +++ b/crates/lab-cli/src/main.rs @@ -1,5 +1,7 @@ mod commands; mod run; +mod scene; +mod simulate; mod typeset; mod update; mod workcell_run; @@ -86,6 +88,33 @@ enum Command { #[arg(long = "station", value_name = "NAME=ADDRESS")] station: Vec, }, + /// Simulate an emitted run package on a virtual clock: how long the + /// work takes, when an operator must be present, and how long each + /// walk-away window lasts. Touches no hardware and writes no ledger; + /// the full record lands in a `lab.sim-trace.v0` trace file. + Simulate { + /// A run directory produced by `lab build`, workcell wave or + /// Hamilton STAR package. + path: PathBuf, + /// Where to write the trace; defaults to `sim-trace.json` beside + /// the plan. + #[arg(long)] + trace: Option, + /// Facility description to simulate against: the plan's stations + /// must exist there, and its transport times drive the handoffs. + #[arg(long)] + facility: Option, + }, + /// Render a built run package as a 3D scene: a `lab.scene.v0` + /// document plus glTF and USD projections of it. + Scene { + /// A run directory produced by `lab build`, workcell wave or + /// Hamilton STAR package. + path: PathBuf, + /// Where to write scene files; defaults to the run directory. + #[arg(long)] + out_dir: Option, + }, /// Print resolved package metadata and source-module names. Metadata { /// Package directory or any path inside a package. @@ -155,7 +184,7 @@ fn run() -> Result<()> { station, } => { if workcell_run::is_workcell_directory(&path) { - workcell_run::run_workcell(path, dry_run, yes, resume, station, &output) + workcell_run::run_workcell_command(path, dry_run, yes, resume, station, &output) } else if resume || !station.is_empty() { anyhow::bail!( "--resume and --station apply to workcell waves; this directory holds a Hamilton STAR package, which re-runs from its documents" @@ -164,6 +193,12 @@ fn run() -> Result<()> { run::run(path, dry_run, yes, &output) } } + Command::Simulate { + path, + trace, + facility, + } => simulate::simulate(path, trace, facility, &output), + Command::Scene { path, out_dir } => scene::scene(path, out_dir, &output), Command::Metadata { path } => commands::metadata(path, &output), Command::Update { check } => update::update(check, &output), } diff --git a/crates/lab-cli/src/run.rs b/crates/lab-cli/src/run.rs index 8c64acc..4b45b70 100644 --- a/crates/lab-cli/src/run.rs +++ b/crates/lab-cli/src/run.rs @@ -1,213 +1,26 @@ -//! Live execution of an emitted Hamilton STAR run package. -//! -//! `lab run ` replays the reviewed `lab.star-run.v0` documents a build -//! produced: every frame is validated through the driver crate before -//! anything moves, the machine runs the documented setup choreography -//! first, and any firmware error retracts the channels and aborts with the -//! failed step named. The dry run prints the full step table and touches no -//! hardware. +//! The `lab run` command for a Hamilton STAR package: terminal presentation +//! over the runtime's loader and replay loop. -use std::fs; -use std::io::{BufRead, Write}; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; -use anyhow::{Context, Result, bail}; -use hamilton_star::{InitializeOptions, RawCommand, Star}; -use lab_compiler::runfmt::{STAR_RUN_FORMAT, StarRunDocument}; -use serde::Deserialize; +use anyhow::{Result, bail}; +use lab_runtime::operator::{ConfirmKind, Operator, StdinOperator}; +use lab_runtime::star::{RunOutcome, execute_runs, load_run_directory, render_dry_run}; use crate::Output; -pub(crate) use lab_compiler::runfmt::ManualStep; - -/// One `lab.star-run.v0` document, loaded and frame-validated. -pub(crate) struct LoadedRun { - pub id: String, - pub title: String, - pub steps: Vec, - pub manual_after: Vec, -} - -pub(crate) struct LoadedStep { - pub command: RawCommand, - pub description: String, -} - -/// The manifest fields the runner reads: run order and the bench's -/// initialize options. -#[derive(Deserialize)] -struct ManifestSummary { - target: String, - runs: Vec, - deck: ManifestDeck, -} - -#[derive(Deserialize)] -struct ManifestRun { - id: String, -} - -#[derive(Deserialize)] -struct ManifestDeck { - #[serde(default)] - run: ManifestRunOptions, -} - -#[derive(Deserialize, Default)] -struct ManifestRunOptions { - #[serde(default)] - autoload_park_track: Option, -} - -/// Loads a run directory: the automation manifest names the run order, and -/// every document's frames must parse before anything is reported ready. -pub(crate) fn load_run_directory(directory: &Path) -> Result<(Vec, Option)> { - let manifest_path = directory.join("automation_manifest.json"); - let manifest_text = fs::read_to_string(&manifest_path).with_context(|| { - format!( - "no automation manifest at {}; point lab run at a directory produced by `lab build` for a hamilton.star target", - manifest_path.display() - ) - })?; - let manifest: ManifestSummary = - serde_json::from_str(&manifest_text).context("failed to parse the automation manifest")?; - if manifest.target != "hamilton.star" { - bail!( - "this package was compiled for '{}'; lab run executes hamilton.star run documents only", - manifest.target - ); - } - - let mut runs = Vec::new(); - for run in &manifest.runs { - let path = directory.join(format!("{}.star.json", run.id)); - let text = fs::read_to_string(&path) - .with_context(|| format!("missing run document {}", path.display()))?; - let document: StarRunDocument = serde_json::from_str(&text) - .with_context(|| format!("failed to parse {}", path.display()))?; - if document.format != STAR_RUN_FORMAT { - bail!( - "{} declares format '{}'; this runner speaks {STAR_RUN_FORMAT}", - path.display(), - document.format - ); - } - let steps = document - .steps - .iter() - .map(|step| { - RawCommand::parse(&step.frame) - .map(|command| LoadedStep { - command, - description: step.description.clone(), - }) - .with_context(|| format!("{} carries an unreplayable frame", path.display())) - }) - .collect::>>()?; - runs.push(LoadedRun { - id: document.run, - title: document.title, - steps, - manual_after: document.manual_after, - }); - } - Ok((runs, manifest.deck.run.autoload_park_track)) -} - -/// The outcome of replaying one package. -#[derive(Debug, PartialEq, Eq)] -pub(crate) enum RunOutcome { - Completed { - steps: usize, - }, - /// A firmware error stopped the run; the channels were retracted and - /// physical state stands at the named step. - Aborted { - run_id: String, - step_index: usize, - error: String, - }, -} - -/// Replays loaded runs over an open session. `pause` is called between -/// runs with the manual-step text and must return `true` to continue — -/// the operator confirms the bench matches before more motion. -pub(crate) fn execute_runs( - star: &Star, - runs: &[LoadedRun], - pause: &mut dyn FnMut(&str) -> bool, - narrate: &mut dyn FnMut(&str), -) -> Result { - let mut executed = 0usize; - for (index, run) in runs.iter().enumerate() { - narrate(&format!( - "run {}: {} ({} steps)", - index + 1, - run.title, - run.steps.len() - )); - for (step_index, step) in run.steps.iter().enumerate() { - narrate(&format!(" [{:>3}] {}", step_index + 1, step.description)); - if let Err(error) = star.execute_raw(&step.command) { - // Any failure leaves the machine mid-motion: retract to - // Z-safety before handing control back. - let retract = RawCommand::parse("C0ZA") - .expect("the retract frame is a constant well-formed frame"); - let _ = star.execute_raw(&retract); - return Ok(RunOutcome::Aborted { - run_id: run.id.clone(), - step_index, - error: error.to_string(), - }); - } - executed += 1; - } - for manual in &run.manual_after { - let prompt = format!("{}: {}", manual.title, manual.instructions); - if !pause(&prompt) { - bail!("run stopped by the operator after '{}'", run.id); - } - } - } - Ok(RunOutcome::Completed { steps: executed }) -} - -/// The `lab run` command. pub(crate) fn run(directory: PathBuf, dry_run: bool, yes: bool, output: &Output) -> Result<()> { let (runs, autoload_park_track) = load_run_directory(&directory)?; let total_steps: usize = runs.iter().map(|run| run.steps.len()).sum(); if dry_run { - let mut human = format!( - "dry run: {} run document(s), {} frames, all validated\n", - runs.len(), - total_steps - ); - for run in &runs { - human.push_str(&format!("\n{} — {}\n", run.id, run.title)); - for (index, step) in run.steps.iter().enumerate() { - human.push_str(&format!( - " [{:>3}] {:<4} {}\n {}\n", - index + 1, - step.command.code(), - step.description, - step.command.frame(), - )); - } - for manual in &run.manual_after { - human.push_str(&format!( - " then by hand — {}: {}\n", - manual.title, manual.instructions - )); - } - } return output.success( "dry-run", serde_json::json!({ "runs": runs.len(), "steps": total_steps, }), - human, + render_dry_run(&runs), ); } @@ -216,23 +29,27 @@ pub(crate) fn run(directory: PathBuf, dry_run: bool, yes: bool, output: &Output) runs.len(), total_steps ); - if !yes && !confirm("proceed? The machine will move. [y/N] ")? { + let mut operator = StdinOperator; + if !yes + && !operator.confirm( + ConfirmKind::PreRun, + "proceed? The machine will move. [y/N] ", + )? + { bail!("run cancelled before any motion"); } - let star = Star::open_usb().context( - "no Hamilton STAR answered on USB; use --dry-run to review the package without hardware", - )?; - println!("connected; running the setup choreography"); - star.initialize(InitializeOptions { - autoload_park_track, - ..InitializeOptions::default() - }) - .context("the setup choreography failed; the machine is not in a known state")?; + let star = lab_runtime::star::open_usb_star(autoload_park_track)?; + println!("connected; the setup choreography has run"); let mut pause = |prompt: &str| { println!("\nby hand — {prompt}"); - confirm("done, and the bench matches the plan? Continue [y/N] ").unwrap_or(false) + StdinOperator + .confirm( + ConfirmKind::Manual, + "done, and the bench matches the plan? Continue [y/N] ", + ) + .unwrap_or(false) }; let mut narrate = |line: &str| println!("{line}"); match execute_runs(&star, &runs, &mut pause, &mut narrate)? { @@ -253,138 +70,3 @@ pub(crate) fn run(directory: PathBuf, dry_run: bool, yes: bool, output: &Output) } } } - -pub(crate) fn confirm(prompt: &str) -> Result { - print!("{prompt}"); - std::io::stdout().flush()?; - let mut answer = String::new(); - std::io::stdin().lock().read_line(&mut answer)?; - Ok(matches!(answer.trim(), "y" | "Y" | "yes")) -} - -/// Test-only session construction over an arbitrary transport, so the -/// replay loop is exercised without hardware. -#[cfg(test)] -pub(crate) fn star_over(transport: std::sync::Arc) -> Result { - Ok(Star::new(transport)?) -} - -#[cfg(test)] -mod tests { - use super::*; - use hamilton_star::{MockTransport, Transport}; - use std::sync::Arc; - - fn loaded(frames: &[(&str, &str)]) -> LoadedRun { - LoadedRun { - id: "test_run".into(), - title: "test".into(), - steps: frames - .iter() - .map(|(frame, description)| LoadedStep { - command: RawCommand::parse(frame).expect("test frames are well-formed"), - description: description.to_string(), - }) - .collect(), - manual_after: Vec::new(), - } - } - - #[test] - fn a_scripted_run_replays_every_frame_in_order() { - let transport = Arc::new(MockTransport::new()); - transport.set_responder(|command| { - let id = command.get(6..10).unwrap_or("0000").to_string(); - vec![format!("{}id{id}er00/00", &command[..4])] - }); - let star = star_over(transport.clone() as Arc).expect("mock opens"); - let runs = vec![loaded(&[ - ("C0TTtt00tf1tl0519tv03600tg2tu0", "define the small tip"), - ("C0ZA", "retract"), - ])]; - let outcome = execute_runs(&star, &runs, &mut |_| true, &mut |_| {}) - .expect("the scripted run completes"); - assert_eq!(outcome, RunOutcome::Completed { steps: 2 }); - let written = transport.written(); - assert_eq!(written.len(), 2, "both frames reached the wire in order"); - assert!( - written[0].starts_with("C0TTid") && written[0].ends_with("tt00tf1tl0519tv03600tg2tu0"), - "the tip definition went first with the session's id spliced in: {}", - written[0] - ); - } - - #[test] - fn a_firmware_error_retracts_and_reports_the_failed_step() { - let transport = Arc::new(MockTransport::new()); - transport.set_responder(|command| { - let id = command.get(6..10).unwrap_or("0000").to_string(); - if &command[2..4] == "TP" { - // The firmware refuses the pickup: a tip is already fitted. - vec![format!("C0TPid{id}er07/00")] - } else { - vec![format!("{}id{id}er00/00", &command[..4])] - } - }); - let star = star_over(transport.clone() as Arc).expect("mock opens"); - let runs = vec![loaded(&[ - ("C0ZA", "retract"), - ( - "C0TPxp01179 01179 00000&yp2418 2328 0000&tm1 1 0&tt01tp2244tz2164th2450td0", - "pick up tips", - ), - ("C0ZA", "never reached"), - ])]; - let outcome = execute_runs(&star, &runs, &mut |_| true, &mut |_| {}) - .expect("an abort is an outcome, not a runner failure"); - let RunOutcome::Aborted { - run_id, - step_index, - error, - } = outcome - else { - panic!("the firmware error aborts the run"); - }; - assert_eq!(run_id, "test_run"); - assert_eq!(step_index, 1, "the pickup was the second step"); - assert!( - error.contains("already fitted"), - "the typed firmware meaning survives into the report: {error}" - ); - let written = transport.written(); - assert!( - written - .last() - .expect("frames were written") - .starts_with("C0ZAid"), - "the runner's last act is the Z-safety retract" - ); - } - - #[test] - fn an_operator_decline_stops_between_runs() { - let transport = Arc::new(MockTransport::new()); - transport.set_responder(|command| { - let id = command.get(6..10).unwrap_or("0000").to_string(); - vec![format!("{}id{id}er00/00", &command[..4])] - }); - let star = star_over(transport.clone() as Arc).expect("mock opens"); - let mut first = loaded(&[("C0ZA", "retract")]); - first.manual_after.push(ManualStep { - title: "thermocycle".into(), - instructions: "off-deck".into(), - }); - let second = loaded(&[("C0ZA", "never reached")]); - let error = execute_runs(&star, &[first, second], &mut |_| false, &mut |_| {}) - .expect_err("declining the manual step stops the program"); - assert!( - error.to_string().contains("stopped by the operator"), - "the stop names its cause: {error}" - ); - assert_eq!( - transport.written().len(), - 1, - "nothing after the declined manual step reached the wire" - ); - } -} diff --git a/crates/lab-cli/src/scene.rs b/crates/lab-cli/src/scene.rs new file mode 100644 index 0000000..6f0299b --- /dev/null +++ b/crates/lab-cli/src/scene.rs @@ -0,0 +1,126 @@ +//! The `lab scene` command: render a built run package as a 3D scene. +//! +//! Every STAR automation manifest embeds the full bench profile it was +//! planned against, so a scene rebuilds from build output alone — no +//! access to `targets/` is needed. The scene document is the semantic +//! source of truth; the glTF and USD files beside it are derived +//! projections for viewers and simulators. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use lab_compiler::backend::hamilton::star::profile::StarTargetProfile; +use lab_scene::workcell::{StationScene, star_bench_scene, workcell_scene}; +use lab_scene::{Scene, gltf::render_gltf, usda::render_usda}; + +use crate::Output; + +/// Reads the bench profile out of a STAR automation manifest. +fn deck_from_manifest(path: &Path) -> Result { + let text = std::fs::read_to_string(path) + .with_context(|| format!("no automation manifest at {}", path.display()))?; + let manifest: serde_json::Value = + serde_json::from_str(&text).context("failed to parse the automation manifest")?; + let mut deck = manifest + .get("deck") + .cloned() + .with_context(|| format!("{} carries no deck profile", path.display()))?; + // A profile never reads its own name from disk (the loader names it), + // so pull the serialized name out before deserializing and put it + // back after. + let name = deck + .get("target") + .and_then(|target| target.get("name")) + .and_then(|name| name.as_str()) + .unwrap_or("bench") + .to_string(); + if let Some(target) = deck + .get_mut("target") + .and_then(|target| target.as_object_mut()) + { + target.remove("name"); + } + let mut profile: StarTargetProfile = serde_json::from_value(deck).with_context(|| { + format!( + "{} carries a deck this scene builder cannot read", + path.display() + ) + })?; + profile.target.name = name; + Ok(profile) +} + +fn build_scene(directory: &Path) -> Result { + if lab_runtime::workcell::is_workcell_directory(directory) { + let plan = lab_runfmt::load_workcell_plan(directory)?; + let mut stations = Vec::new(); + for station in &plan.stations { + let star_profile = if station.kind == "hamilton.star" { + let manifest = directory + .join(&station.program_dir) + .join("automation_manifest.json"); + Some(deck_from_manifest(&manifest)?) + } else { + None + }; + stations.push(StationScene { + name: station.name.clone(), + kind: station.kind.clone(), + star_profile, + }); + } + let name = directory + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| "workcell".to_string()); + Ok(workcell_scene(&name, stations)?) + } else { + let manifest = directory.join("automation_manifest.json"); + if !manifest.is_file() { + bail!( + "{} holds neither a workcell plan nor a STAR automation manifest; point lab scene at a directory produced by `lab build`", + directory.display() + ); + } + let profile = deck_from_manifest(&manifest)?; + let name = profile.target.name.clone(); + Ok(star_bench_scene(&name, &profile)?) + } +} + +pub(crate) fn scene(directory: PathBuf, out_dir: Option, output: &Output) -> Result<()> { + let scene = build_scene(&directory)?; + let out_dir = out_dir.unwrap_or_else(|| directory.clone()); + std::fs::create_dir_all(&out_dir) + .with_context(|| format!("failed to create {}", out_dir.display()))?; + + let scene_path = out_dir.join("scene.json"); + std::fs::write(&scene_path, serde_json::to_string_pretty(&scene)?) + .with_context(|| format!("failed to write {}", scene_path.display()))?; + let gltf_path = out_dir.join("scene.gltf"); + std::fs::write(&gltf_path, render_gltf(&scene)) + .with_context(|| format!("failed to write {}", gltf_path.display()))?; + let usda_path = out_dir.join("scene.usda"); + std::fs::write(&usda_path, render_usda(&scene)) + .with_context(|| format!("failed to write {}", usda_path.display()))?; + + let mut nodes = 0usize; + scene.root.walk(&mut |_, _| nodes += 1); + output.success( + "scene", + serde_json::json!({ + "name": scene.name, + "nodes": nodes, + "scene": scene_path.display().to_string(), + "gltf": gltf_path.display().to_string(), + "usda": usda_path.display().to_string(), + }), + format!( + "scene '{}': {nodes} node(s)\n {}\n {}\n {}", + scene.name, + scene_path.display(), + gltf_path.display(), + usda_path.display() + ), + ) +} diff --git a/crates/lab-cli/src/simulate.rs b/crates/lab-cli/src/simulate.rs new file mode 100644 index 0000000..928150a --- /dev/null +++ b/crates/lab-cli/src/simulate.rs @@ -0,0 +1,140 @@ +//! The `lab simulate` command: the third interpreter of a run package. +//! +//! Simulation walks the same documents `lab run` executes, on a virtual +//! clock, and reports what the experiment costs in time: total duration, +//! when an operator must be present, and how long each walk-away window +//! lasts. The full record is written as a `lab.sim-trace.v0` trace. + +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use lab_runtime::clock::{Clock, WallClock}; +use lab_runtime::durations::DurationModel; +use lab_runtime::events::RunEvent; +use lab_runtime::simulate::{SimulationConfig, simulate_star_package, simulate_workcell}; +use lab_runtime::trace::{SimTraceDocument, TimedEvent}; +use lab_runtime::workcell::{is_workcell_directory, load_workcell_directory}; + +use crate::Output; + +/// The trace file a simulation writes beside the package it simulated. +const TRACE_FILE: &str = "sim-trace.json"; + +pub(crate) fn simulate( + directory: PathBuf, + trace_path: Option, + facility_path: Option, + output: &Output, +) -> Result<()> { + let mut durations = DurationModel::default(); + let facility = facility_path + .map(|path| lab_runtime::facility::load_facility(&path)) + .transpose()?; + if let Some(facility) = &facility { + // The facility's transport time is the whole handoff: seal, carry, + // seat, confirm. + durations.handoff_seconds = facility.transport.walk_seconds; + if is_workcell_directory(&directory) { + let plan = lab_runfmt::load_workcell_plan(&directory)?; + facility.check_stations(&plan.stations)?; + } + } + let config = SimulationConfig { + origin_unix: WallClock.now_unix(), + durations, + }; + let trace = if is_workcell_directory(&directory) { + let loaded = load_workcell_directory(&directory)?; + simulate_workcell(&loaded, config)? + } else { + simulate_star_package(&directory, config)? + }; + + let trace_path = trace_path.unwrap_or_else(|| directory.join(TRACE_FILE)); + let text = serde_json::to_string_pretty(&trace)?; + std::fs::write(&trace_path, text) + .with_context(|| format!("failed to write {}", trace_path.display()))?; + + let human = render_timeline(&trace, &trace_path); + output.success("simulate", &trace, human) +} + +fn hms(seconds: f64) -> String { + let total = seconds.round() as u64; + format!( + "{:02}:{:02}:{:02}", + total / 3600, + (total % 3600) / 60, + total % 60 + ) +} + +/// One line per node, then the numbers that plan a lab day: attended +/// windows and the walk-away stretches between them. +fn render_timeline(trace: &SimTraceDocument, trace_path: &std::path::Path) -> String { + use std::fmt::Write; + let mut text = format!( + "simulated: {} node(s) in {} (durations: {} — estimates, calibrate against real ledgers)\n\n", + trace.summary.nodes, + hms(trace.summary.total_seconds), + trace.durations, + ); + + // Node table from start/complete pairs, marking attended nodes. + let mut started: Option<(&str, f64)> = None; + for TimedEvent { t, event } in &trace.events { + match event { + RunEvent::NodeStarted { id } => started = Some((id, *t)), + RunEvent::NodeCompleted { id } => { + if let Some((start_id, from)) = started.take() + && start_id == id + { + let attended = trace + .summary + .attention_windows + .iter() + .any(|window| window.node == *id); + let _ = writeln!( + text, + " t+{} {:<40} {:>9} {}", + hms(from), + id, + hms(t - from), + if attended { "attended" } else { "unattended" } + ); + } + } + _ => {} + } + } + + let _ = write!( + text, + "\ntotal {}; attended {} in {} window(s); walk-away {}", + hms(trace.summary.total_seconds), + hms(trace.summary.attended_seconds), + trace.summary.attention_windows.len(), + hms(trace.summary.walkaway_seconds), + ); + if let Some(longest) = longest_walkaway(trace) { + let _ = write!(text, "; longest walk-away {}", hms(longest)); + } + let _ = write!(text, "\ntrace: {}", trace_path.display()); + text +} + +/// The longest stretch with no operator needed: the gaps between attention +/// windows, plus the run's unattended head and tail. +fn longest_walkaway(trace: &SimTraceDocument) -> Option { + let windows = &trace.summary.attention_windows; + if windows.is_empty() { + return (trace.summary.total_seconds > 0.0).then_some(trace.summary.total_seconds); + } + let mut gaps = Vec::new(); + gaps.push(windows[0].from_seconds); + for pair in windows.windows(2) { + gaps.push(pair[1].from_seconds - pair[0].to_seconds); + } + gaps.push(trace.summary.total_seconds - windows[windows.len() - 1].to_seconds); + gaps.into_iter().reduce(f64::max) +} diff --git a/crates/lab-cli/src/workcell_run.rs b/crates/lab-cli/src/workcell_run.rs index e359f31..251e2a3 100644 --- a/crates/lab-cli/src/workcell_run.rs +++ b/crates/lab-cli/src/workcell_run.rs @@ -1,342 +1,74 @@ -//! Live execution of an emitted workcell wave. -//! -//! `lab run ` on a directory holding `plan.workcell.json` walks -//! the coordination plan in order: station programs run on their -//! instruments, and every handoff or manual step stops for the operator's -//! confirmation. A durable ledger records each node as it completes, so an -//! interrupted wave — a crash, a power cut, an overnight incubation — -//! resumes from the first incomplete node with `--resume` instead of -//! repeating motion that already happened. - -use std::collections::BTreeSet; -use std::fs; -use std::io::Write as _; -use std::path::{Path, PathBuf}; -use std::time::{SystemTime, UNIX_EPOCH}; - -use anyhow::{Context, Result, bail}; -use hamilton_star::RawCommand; -use lab_compiler::runfmt::{ - STAR_RUN_FORMAT, StarRunDocument, THERMOCYCLE_RUN_FORMAT, ThermocycleRunDocument, - WORKCELL_RUN_FORMAT, WorkcellAction, WorkcellRunDocument, +//! The `lab run` command for a workcell wave: terminal presentation over +//! the runtime's workcell walk. + +use std::path::PathBuf; + +use anyhow::{Result, bail}; +use lab_runtime::clock::WallClock; +use lab_runtime::events::{EventSink, ProgramExtent, RunEvent}; +use lab_runtime::operator::StdinOperator; +use lab_runtime::stations::HardwareConnector; +use lab_runtime::workcell::{ + Bench, RunConfig, WorkcellOutcome, load_workcell_directory, parse_station_addresses, + render_dry_run, run_workcell, }; -use serde::{Deserialize, Serialize}; - -/// The ledger file a wave accumulates beside its plan. -pub(crate) const LEDGER_FILE: &str = "run-ledger.jsonl"; - -/// One appended ledger record. The ledger is the run's memory and its -/// evidence: which nodes completed, when, and on whose confirmation. -#[derive(Debug, Serialize, Deserialize)] -pub(crate) struct LedgerEntry { - pub node: String, - pub event: LedgerEvent, - /// Wall-clock seconds since the Unix epoch. - pub at_unix_seconds: u64, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub(crate) enum LedgerEvent { - Started, - Confirmed, - Completed, - Failed, -} - -fn now_unix_seconds() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .unwrap_or(0) -} - -/// Appends one entry; every event is durable before the walk continues. -pub(crate) fn append_ledger(directory: &Path, node: &str, event: LedgerEvent) -> Result<()> { - let entry = LedgerEntry { - node: node.to_string(), - event, - at_unix_seconds: now_unix_seconds(), - }; - let mut line = serde_json::to_string(&entry)?; - line.push('\n'); - let path = directory.join(LEDGER_FILE); - let mut file = fs::OpenOptions::new() - .create(true) - .append(true) - .open(&path) - .with_context(|| format!("failed to open {}", path.display()))?; - file.write_all(line.as_bytes()) - .with_context(|| format!("failed to append to {}", path.display()))?; - Ok(()) -} - -/// The node ids the ledger records as completed. -pub(crate) fn completed_nodes(directory: &Path) -> Result> { - let path = directory.join(LEDGER_FILE); - if !path.is_file() { - return Ok(BTreeSet::new()); - } - let text = - fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; - let mut completed = BTreeSet::new(); - for (number, line) in text.lines().enumerate() { - if line.trim().is_empty() { - continue; - } - let entry: LedgerEntry = serde_json::from_str(line).with_context(|| { - format!( - "{} line {} is not a ledger entry", - path.display(), - number + 1 - ) - })?; - if entry.event == LedgerEvent::Completed { - completed.insert(entry.node); - } - } - Ok(completed) -} - -/// A station program, loaded and validated up front so nothing is -/// discovered mid-walk. -pub(crate) enum LoadedProgram { - Star { - station: String, - document: StarRunDocument, - steps: Vec<(RawCommand, String)>, - }, - Thermocycle { - station: String, - document: ThermocycleRunDocument, - }, -} - -/// One executable unit of the walk, in plan order. -pub(crate) struct LoadedNode { - pub id: String, - pub action: LoadedAction, -} - -pub(crate) enum LoadedAction { - Program(LoadedProgram), - Handoff { - from: String, - to: String, - instructions: String, - }, - Manual { - title: String, - instructions: String, - }, -} - -pub(crate) struct LoadedWorkcell { - pub nodes: Vec, - /// The thermocycler station's name, when the plan declares one. - pub thermocycler_station: Option, -} - -/// True when the directory holds a workcell coordination plan. -pub(crate) fn is_workcell_directory(directory: &Path) -> bool { - directory.join("plan.workcell.json").is_file() -} - -/// Loads a wave directory: the coordination plan names every node, and -/// every referenced station document must parse and validate before -/// anything is reported ready. -pub(crate) fn load_workcell_directory(directory: &Path) -> Result { - let plan_path = directory.join("plan.workcell.json"); - let plan_text = fs::read_to_string(&plan_path) - .with_context(|| format!("failed to read {}", plan_path.display()))?; - let plan: WorkcellRunDocument = - serde_json::from_str(&plan_text).context("failed to parse the coordination plan")?; - if plan.format != WORKCELL_RUN_FORMAT { - bail!( - "{} declares format '{}'; this runner speaks {WORKCELL_RUN_FORMAT}", - plan_path.display(), - plan.format - ); - } - - let station_kind = |name: &str| -> Result<&str> { - plan.stations - .iter() - .find(|station| station.name == name) - .map(|station| station.kind.as_str()) - .with_context(|| format!("the plan references station '{name}' it never declares")) - }; - let mut nodes = Vec::new(); - for node in &plan.nodes { - let action = match &node.action { - WorkcellAction::StationProgram { station, document } => { - let path = directory.join(document); - let text = fs::read_to_string(&path) - .with_context(|| format!("missing station document {}", path.display()))?; - match station_kind(station)? { - "hamilton.star" => { - let document: StarRunDocument = serde_json::from_str(&text) - .with_context(|| format!("failed to parse {}", path.display()))?; - if document.format != STAR_RUN_FORMAT { - bail!( - "{} declares format '{}'; station '{station}' runs {STAR_RUN_FORMAT} documents", - path.display(), - document.format - ); - } - let steps = document - .steps - .iter() - .map(|step| { - RawCommand::parse(&step.frame) - .map(|command| (command, step.description.clone())) - .with_context(|| { - format!("{} carries an unreplayable frame", path.display()) - }) - }) - .collect::>>()?; - LoadedAction::Program(LoadedProgram::Star { - station: station.clone(), - document, - steps, - }) - } - "inheco.odtc" => { - let document: ThermocycleRunDocument = serde_json::from_str(&text) - .with_context(|| format!("failed to parse {}", path.display()))?; - if document.format != THERMOCYCLE_RUN_FORMAT { - bail!( - "{} declares format '{}'; station '{station}' runs {THERMOCYCLE_RUN_FORMAT} documents", - path.display(), - document.format - ); - } - LoadedAction::Program(LoadedProgram::Thermocycle { - station: station.clone(), - document, - }) - } - other => bail!( - "station '{station}' has kind '{other}', which this runner has no executor for" - ), +pub(crate) use lab_runtime::workcell::is_workcell_directory; + +/// The terminal sink: narrates the walk the way an operator at the bench +/// reads it. +struct HumanSink; + +impl EventSink for HumanSink { + fn emit(&mut self, event: RunEvent) { + match event { + RunEvent::Planned { pending, completed } => println!( + "about to execute {pending} coordination node(s){}", + if completed == 0 { + String::new() + } else { + format!(", resuming past {completed} completed") } + ), + RunEvent::Connecting { station, detail } => { + println!("connecting to {station} ({detail})") } - WorkcellAction::Handoff { - from, - to, - labware, - instructions, - } => LoadedAction::Handoff { - from: from.clone(), - to: to.clone(), - instructions: format!("{instructions} ({labware}: {from} -> {to})"), - }, - WorkcellAction::Manual { + RunEvent::Connected { station } => println!("connected; {station} is ready"), + RunEvent::NodeSkipped { id } => println!("skipping {id} (completed in the ledger)"), + RunEvent::NodeStarted { .. } | RunEvent::NodeCompleted { .. } => {} + RunEvent::ProgramStarted { + station, title, - instructions, - } => LoadedAction::Manual { - title: title.clone(), - instructions: instructions.clone(), + extent, + } => match extent { + ProgramExtent::Frames { frames } => { + println!("\n{station}: {title} ({frames} frames)") + } + ProgramExtent::Plateaus { plateaus, .. } => { + println!("\n{station}: {title} ({plateaus} plateaus)") + } }, - }; - nodes.push(LoadedNode { - id: node.id.clone(), - action, - }); - } - let thermocycler_station = plan - .stations - .iter() - .find(|station| station.kind == "inheco.odtc") - .map(|station| station.name.clone()); - Ok(LoadedWorkcell { - nodes, - thermocycler_station, - }) -} - -/// Renders the dry-run walk: every node in order, with program contents -/// summarized the way the live run narrates them. -pub(crate) fn render_dry_run(loaded: &LoadedWorkcell) -> String { - use std::fmt::Write; - let mut text = String::new(); - let _ = writeln!( - text, - "dry run: {} coordination node(s), all documents validated", - loaded.nodes.len() - ); - for (index, node) in loaded.nodes.iter().enumerate() { - match &node.action { - LoadedAction::Program(LoadedProgram::Star { - station, - document, - steps, - }) => { - let _ = writeln!( - text, - "\n[{}] {} on {station} — {} ({} frames)", - index + 1, - node.id, - document.title, - steps.len() - ); - } - LoadedAction::Program(LoadedProgram::Thermocycle { station, document }) => { - let _ = writeln!( - text, - "\n[{}] {} on {station} — {} ({} plateaus{})", - index + 1, - node.id, - document.title, - document.profile.total_steps(), - match document.final_hold_celsius { - Some(celsius) => format!(", then hold {celsius} °C"), - None => String::new(), - } - ); - } - LoadedAction::Handoff { instructions, .. } => { - let _ = writeln!( - text, - "\n[{}] {} — by hand: {instructions}", - index + 1, - node.id - ); + RunEvent::Frame { + index, description, .. + } => println!(" [{index:>3}] {description}"), + RunEvent::ThermalRunning { .. } => println!( + "running; completion may take hours — the wave resumes with --resume if interrupted" + ), + RunEvent::ThermalWarning { station, warning } => { + println!("{station} warning: {warning}") } - LoadedAction::Manual { - title, - instructions, - } => { - let _ = writeln!( - text, - "\n[{}] {} — by hand: {title}: {instructions}", - index + 1, - node.id - ); + RunEvent::ThermalHold { celsius, .. } => { + println!("holding the block at {celsius} °C until retrieval") } + RunEvent::DoorOpened { station } => println!("{station} door is open"), + RunEvent::DoorClosed { station } => println!("{station} door is closed"), + RunEvent::AttentionRequired { prompt, .. } => println!("\nby hand — {prompt}"), + RunEvent::AttentionReleased { .. } | RunEvent::LabwareMoved { .. } => {} } } - text -} - -/// The connected stations a walk accumulates: each opens on first use and -/// stays open for the wave. -struct Sessions { - star: Option, - odtc: Option, } -/// Bench context the walk carries: which station is the cycler, and where -/// stations answer on this bench. Addresses are runtime input — compiled -/// artifacts never carry them. -struct Bench { - thermocycler_station: Option, - addresses: std::collections::BTreeMap, -} - -/// The workcell `lab run` flow: validate everything, then walk. -pub(crate) fn run_workcell( +pub(crate) fn run_workcell_command( directory: PathBuf, dry_run: bool, yes: bool, @@ -345,267 +77,56 @@ pub(crate) fn run_workcell( output: &crate::Output, ) -> Result<()> { let loaded = load_workcell_directory(&directory)?; - let mut addresses = std::collections::BTreeMap::new(); - for entry in &station_addresses { - let Some((name, address)) = entry.split_once('=') else { - bail!("--station takes NAME=ADDRESS, e.g. --station odtc-1=169.254.10.40:8080"); - }; - addresses.insert(name.to_string(), address.to_string()); - } + let addresses = parse_station_addresses(&station_addresses)?; if dry_run { - let human = render_dry_run(&loaded); return output.success( "dry-run", serde_json::json!({ "nodes": loaded.nodes.len() }), - human, + render_dry_run(&loaded), ); } - let completed = if resume { - completed_nodes(&directory)? - } else { - let ledger = directory.join(LEDGER_FILE); - if ledger.is_file() { - bail!( - "{} already exists; a wave that stopped mid-run continues with --resume, and a fresh run of the same wave means physical state this runner cannot verify — remove the ledger only if the bench was truly reset", - ledger.display() - ); - } - BTreeSet::new() - }; - - let pending: Vec<&LoadedNode> = loaded - .nodes - .iter() - .filter(|node| !completed.contains(&node.id)) - .collect(); - println!( - "about to execute {} coordination node(s){}", - pending.len(), - if completed.is_empty() { - String::new() - } else { - format!(", resuming past {} completed", completed.len()) - } - ); - if !yes && !crate::run::confirm("proceed? Stations will move. [y/N] ")? { - bail!("run cancelled before any motion"); - } - let bench = Bench { thermocycler_station: loaded.thermocycler_station.clone(), addresses, }; - let mut sessions = Sessions { - star: None, - odtc: None, + let config = RunConfig { + assume_yes: yes, + resume, }; - let mut executed = 0usize; - for node in &loaded.nodes { - if completed.contains(&node.id) { - println!("skipping {} (completed in the ledger)", node.id); - continue; - } - append_ledger(&directory, &node.id, LedgerEvent::Started)?; - let outcome = execute_node(node, &mut sessions, &bench); - match outcome { - Ok(()) => { - append_ledger(&directory, &node.id, LedgerEvent::Completed)?; - executed += 1; - } - Err(error) => { - append_ledger(&directory, &node.id, LedgerEvent::Failed)?; - bail!( - "node '{}' failed: {error}; the ledger holds every completed node — resolve the bench and continue with --resume", - node.id - ); - } - } - } - output.success( - "run", - serde_json::json!({ "nodes": executed, "skipped": completed.len() }), - format!( - "completed {executed} coordination node(s){}", - if completed.is_empty() { - String::new() - } else { - format!(" ({} skipped as already complete)", completed.len()) - } - ), - ) -} - -fn ensure_star(sessions: &mut Sessions) -> Result<&hamilton_star::Star> { - if sessions.star.is_none() { - println!("connecting to the first Hamilton STAR on USB"); - let star = hamilton_star::Star::open_usb().context( - "no Hamilton STAR answered on USB; use --dry-run to review without hardware", - )?; - println!("connected; running the setup choreography"); - star.initialize(hamilton_star::InitializeOptions::default()) - .context("the setup choreography failed; the machine is not in a known state")?; - sessions.star = Some(star); - } - Ok(sessions.star.as_ref().expect("just ensured")) -} - -fn ensure_odtc<'sessions>( - sessions: &'sessions mut Sessions, - bench: &Bench, - station: &str, -) -> Result<&'sessions mut lab_instruments::OdtcStation> { - if sessions.odtc.is_none() { - let address = bench.addresses.get(station).with_context(|| { + let mut connector = HardwareConnector; + let mut operator = StdinOperator; + let mut sink = HumanSink; + let outcome = run_workcell( + &directory, + &loaded, + &bench, + &config, + &mut connector, + &mut operator, + &mut sink, + &WallClock, + )?; + match outcome { + WorkcellOutcome::Completed { executed, skipped } => output.success( + "run", + serde_json::json!({ "nodes": executed, "skipped": skipped }), format!( - "station '{station}' has no address on this bench; pass --station {station}= (the ODTC answers on port 8080)" - ) - })?; - let socket: std::net::SocketAddr = address.parse().with_context(|| { - format!("'{address}' is not an address for station '{station}'") - })?; - println!("connecting to {station} at {socket}"); - let session = lab_instruments::OdtcStation::connect(socket) - .with_context(|| format!("the {station} connection handshake failed at {socket}"))?; - println!("connected; {station} is idle"); - sessions.odtc = Some(session); - } - Ok(sessions.odtc.as_mut().expect("just ensured")) -} - -/// True when a handoff endpoint is the cycler, whose motorized door the -/// runner must open before the operator can reach the block. -fn involves_cycler(bench: &Bench, station: &str) -> bool { - bench - .thermocycler_station - .as_deref() - .is_some_and(|cycler| cycler == station) -} - -fn execute_node(node: &LoadedNode, sessions: &mut Sessions, bench: &Bench) -> Result<()> { - use lab_instruments::Thermocycler as _; - match &node.action { - LoadedAction::Handoff { - from, - to, - instructions, - } => { - let to_cycler = involves_cycler(bench, to); - let from_cycler = involves_cycler(bench, from); - if to_cycler || from_cycler { - let station = if to_cycler { to } else { from }; - let odtc = ensure_odtc(sessions, bench, station)?; - odtc.open_lid() - .with_context(|| format!("could not open the {station} door"))?; - println!("{station} door is open"); - } - println!("\nby hand — {instructions}"); - if !crate::run::confirm("done, and the bench matches the plan? Continue [y/N] ")? { - bail!("the operator declined the handoff"); - } - if to_cycler || from_cycler { - let station = if to_cycler { to } else { from }; - let odtc = ensure_odtc(sessions, bench, station)?; - if from_cycler { - // The plate is out; nothing holds temperature for it now. - odtc.stop() - .with_context(|| format!("could not stop {station} after retrieval"))?; - } - odtc.close_lid() - .with_context(|| format!("could not close the {station} door"))?; - println!("{station} door is closed"); - } - Ok(()) - } - LoadedAction::Manual { - title, - instructions, - } => { - println!("\nby hand — {title}: {instructions}"); - if !crate::run::confirm("done, and the bench matches the plan? Continue [y/N] ")? { - bail!("the operator declined the manual step"); - } - Ok(()) - } - LoadedAction::Program(LoadedProgram::Star { - station, - document, - steps, - }) => { - ensure_star(sessions)?; - let star = sessions.star.as_ref().expect("just ensured"); - println!("\n{station}: {} ({} frames)", document.title, steps.len()); - for (index, (command, description)) in steps.iter().enumerate() { - println!(" [{:>3}] {description}", index + 1); - if let Err(error) = star.execute_raw(command) { - let retract = RawCommand::parse("C0ZA") - .expect("the retract frame is a constant well-formed frame"); - let _ = star.execute_raw(&retract); - bail!( - "firmware error at frame {}: {error}; channels were retracted to Z-safety", - index + 1 - ); + "completed {executed} coordination node(s){}", + if skipped == 0 { + String::new() + } else { + format!(" ({skipped} skipped as already complete)") } - } - Ok(()) - } - LoadedAction::Program(LoadedProgram::Thermocycle { station, document }) => { - let odtc = ensure_odtc(sessions, bench, station)?; - document - .profile - .validate(&lab_instruments::odtc_thermal_limits()) - .with_context(|| format!("'{}' is outside the {station} envelope", document.id))?; - println!( - "\n{station}: {} ({} plateaus)", - document.title, - document.profile.total_steps() - ); - let handle = odtc - .run_profile(&document.profile) - .with_context(|| format!("could not start '{}' on {station}", document.id))?; - println!( - "running; completion may take hours — the wave resumes with --resume if interrupted" - ); - odtc.await_completion(handle) - .with_context(|| format!("'{}' did not complete on {station}", document.id))?; - for warning in odtc.take_warnings() { - println!("{station} warning: {warning}"); - } - if let Some(celsius) = document.final_hold_celsius { - println!("holding the block at {celsius} °C until retrieval"); - odtc.hold_block(celsius, None) - .with_context(|| format!("could not hold {celsius} °C on {station}"))?; - } - Ok(()) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn the_ledger_round_trips_and_reports_completed_nodes() { - let directory = std::env::temp_dir().join(format!( - "lab-ledger-test-{}-{}", - std::process::id(), - line!() - )); - fs::create_dir_all(&directory).unwrap(); - append_ledger(&directory, "assembly_run", LedgerEvent::Started).unwrap(); - append_ledger(&directory, "assembly_run", LedgerEvent::Completed).unwrap(); - append_ledger(&directory, "assembly_thermocycle", LedgerEvent::Started).unwrap(); - let completed = completed_nodes(&directory).unwrap(); - assert!( - completed.contains("assembly_run"), - "a completed node is remembered" - ); - assert!( - !completed.contains("assembly_thermocycle"), - "a started-but-unfinished node is not skipped on resume" - ); - fs::remove_dir_all(&directory).unwrap(); + ), + ), + WorkcellOutcome::Cancelled => bail!("run cancelled before any motion"), + WorkcellOutcome::Declined { node } => bail!( + "node '{node}' failed: the operator declined; the ledger holds every completed node — resolve the bench and continue with --resume" + ), + WorkcellOutcome::Failed { node, error } => bail!( + "node '{node}' failed: {error}; the ledger holds every completed node — resolve the bench and continue with --resume" + ), } } diff --git a/crates/lab-cli/tests/project_workflow.rs b/crates/lab-cli/tests/project_workflow.rs index a219ff1..da2f808 100644 --- a/crates/lab-cli/tests/project_workflow.rs +++ b/crates/lab-cli/tests/project_workflow.rs @@ -577,3 +577,165 @@ fn a_workcell_wave_dry_runs_through_the_coordination_plan() { std::fs::remove_dir_all(&out_dir).unwrap(); } + +#[test] +fn a_workcell_wave_simulates_with_attended_and_walkaway_time() { + let example = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../examples/golden-gate") + .canonicalize() + .unwrap(); + let out_dir = std::env::temp_dir().join(format!( + "lab-golden-gate-simulate-{}-{}", + std::process::id(), + line!() + )); + if out_dir.exists() { + std::fs::remove_dir_all(&out_dir).unwrap(); + } + + let output = Command::new(env!("CARGO_BIN_EXE_lab")) + .args([ + "build", + example.to_str().unwrap(), + "--target", + "workcell-star", + "--out-dir", + out_dir.to_str().unwrap(), + "--json", + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "workcell target build failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let wave = out_dir.join("workcell-star/wave-001"); + + let simulated = Command::new(env!("CARGO_BIN_EXE_lab")) + .args(["simulate", wave.to_str().unwrap(), "--json"]) + .output() + .unwrap(); + assert!( + simulated.status.success(), + "simulate failed: {}", + String::from_utf8_lossy(&simulated.stderr) + ); + let report: Value = serde_json::from_slice(&simulated.stdout).unwrap(); + assert_eq!(report["status"], "simulate"); + let trace = &report["result"]; + assert_eq!(trace["format"], "lab.sim-trace.v0"); + + let summary = &trace["summary"]; + let total = summary["total_seconds"].as_f64().unwrap(); + let attended = summary["attended_seconds"].as_f64().unwrap(); + assert!(total > 0.0, "simulated work takes time"); + assert!( + attended > 0.0 && attended < total, + "handoffs are attended, machine time is not: attended {attended} of {total}" + ); + assert!( + summary["stations"].get("odtc-1").is_some(), + "the cycler reports busy time" + ); + + // Simulation interprets the same plan the dry run narrates: one + // started node per coordination node, in the same order. + let dry = Command::new(env!("CARGO_BIN_EXE_lab")) + .args(["run", wave.to_str().unwrap(), "--dry-run", "--json"]) + .output() + .unwrap(); + assert!(dry.status.success()); + let dry_report: Value = serde_json::from_slice(&dry.stdout).unwrap(); + let plan: Value = + serde_json::from_str(&std::fs::read_to_string(wave.join("plan.workcell.json")).unwrap()) + .unwrap(); + let plan_ids: Vec<&str> = plan["nodes"] + .as_array() + .unwrap() + .iter() + .map(|node| node["id"].as_str().unwrap()) + .collect(); + assert_eq!( + dry_report["result"]["nodes"].as_u64().unwrap() as usize, + plan_ids.len() + ); + let started: Vec<&str> = trace["events"] + .as_array() + .unwrap() + .iter() + .filter(|event| event["event"] == "node-started") + .map(|event| event["id"].as_str().unwrap()) + .collect(); + assert_eq!(started, plan_ids, "simulate walks the plan in plan order"); + + // The trace document landed beside the plan, and no ledger did. + assert!(wave.join("sim-trace.json").is_file()); + assert!( + !wave.join("run-ledger.jsonl").exists(), + "simulation writes a trace, never a ledger" + ); + + std::fs::remove_dir_all(&out_dir).unwrap(); +} + +#[test] +fn a_built_wave_renders_as_a_scene_with_exact_well_positions() { + let example = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../examples/golden-gate") + .canonicalize() + .unwrap(); + let out_dir = std::env::temp_dir().join(format!( + "lab-golden-gate-scene-{}-{}", + std::process::id(), + line!() + )); + if out_dir.exists() { + std::fs::remove_dir_all(&out_dir).unwrap(); + } + + let output = Command::new(env!("CARGO_BIN_EXE_lab")) + .args([ + "build", + example.to_str().unwrap(), + "--target", + "workcell-star", + "--out-dir", + out_dir.to_str().unwrap(), + "--json", + ]) + .output() + .unwrap(); + assert!(output.status.success()); + let wave = out_dir.join("workcell-star/wave-001"); + + let rendered = Command::new(env!("CARGO_BIN_EXE_lab")) + .args(["scene", wave.to_str().unwrap(), "--json"]) + .output() + .unwrap(); + assert!( + rendered.status.success(), + "scene failed: {}", + String::from_utf8_lossy(&rendered.stderr) + ); + let report: Value = serde_json::from_slice(&rendered.stdout).unwrap(); + assert_eq!(report["status"], "scene"); + assert!(report["result"]["nodes"].as_u64().unwrap() > 100); + + let scene: Value = + serde_json::from_str(&std::fs::read_to_string(wave.join("scene.json")).unwrap()).unwrap(); + assert_eq!(scene["format"], "lab.scene.v0"); + // Both plan stations render, and the reaction plate keeps its plan + // resource name so trace events bind to it. + let text = std::fs::read_to_string(wave.join("scene.json")).unwrap(); + assert!(text.contains("\"reaction_plate\"")); + assert!(text.contains("odtc-1")); + + let gltf: Value = + serde_json::from_str(&std::fs::read_to_string(wave.join("scene.gltf")).unwrap()).unwrap(); + assert_eq!(gltf["asset"]["version"], "2.0"); + let usda = std::fs::read_to_string(wave.join("scene.usda")).unwrap(); + assert!(usda.starts_with("#usda 1.0")); + + std::fs::remove_dir_all(&out_dir).unwrap(); +} diff --git a/crates/lab-compiler/Cargo.toml b/crates/lab-compiler/Cargo.toml index 39be948..69d4894 100644 --- a/crates/lab-compiler/Cargo.toml +++ b/crates/lab-compiler/Cargo.toml @@ -27,6 +27,7 @@ clap.workspace = true # features off). hamilton-star.workspace = true lab-instruments.workspace = true +lab-runfmt.workspace = true lab-language.workspace = true opentrons-protocol.workspace = true pliron.workspace = true diff --git a/crates/lab-compiler/src/lib.rs b/crates/lab-compiler/src/lib.rs index 56d604f..53d9a72 100644 --- a/crates/lab-compiler/src/lib.rs +++ b/crates/lab-compiler/src/lib.rs @@ -4,7 +4,7 @@ pub mod artifact; pub mod backend; pub mod lair; pub mod planning; -pub mod runfmt; +pub use lab_runfmt as runfmt; #[cfg(test)] mod test_support; diff --git a/crates/lab-compiler/src/planning/model.rs b/crates/lab-compiler/src/planning/model.rs index 2f48c3e..875944e 100644 --- a/crates/lab-compiler/src/planning/model.rs +++ b/crates/lab-compiler/src/planning/model.rs @@ -11,6 +11,36 @@ pub struct BuildInventory { pub available_artifacts: BTreeSet, } +impl BuildInventory { + /// This inventory narrowed to what the graph can consume. A package + /// manifest declares exactly the stock its build uses, and resolution + /// rejects any surplus; a facility stocks a whole lab, so a build + /// drawing on one narrows the stock to the graph's demands first and + /// keeps that rejection meaningful for manifests. + pub fn restricted_to(&self, graph: &BuildGraph) -> BuildInventory { + let required: BTreeSet<&String> = graph + .nodes + .values() + .flat_map(|node| node.required_materials.iter()) + .collect(); + let produced: BTreeSet<&String> = graph.nodes.keys().collect(); + BuildInventory { + available_materials: self + .available_materials + .iter() + .filter(|name| required.contains(name)) + .cloned() + .collect(), + available_artifacts: self + .available_artifacts + .iter() + .filter(|name| produced.contains(name)) + .cloned() + .collect(), + } + } +} + /// A target-neutral artifact dependency graph. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct BuildGraph { diff --git a/crates/lab-package/src/manifest.rs b/crates/lab-package/src/manifest.rs index f7b90c9..c119017 100644 --- a/crates/lab-package/src/manifest.rs +++ b/crates/lab-package/src/manifest.rs @@ -79,6 +79,12 @@ pub struct BuildMetadata { /// line, resolved by filename under `targets/`. A package without one /// builds portable module IR and stops. pub target: Option, + /// Facility a simulation runs against when none is named on the command + /// line, resolved by filename under `facilities/`. A facility describes + /// the lab — stations, storage, transport — and is shared by every + /// package that runs there, so it lives in its own file and the + /// manifest carries only this pointer. + pub facility: Option, } /// What a target build may draw on before it plans anything: the materials an @@ -177,6 +183,13 @@ impl PackageManifest { { return Err(PackageError::InvalidTarget(target.clone())); } + // A facility is a filename under `facilities/`, held to the same + // rule as targets: it must not be able to reach outside. + if let Some(facility) = &self.build.facility + && !valid_target_name(facility) + { + return Err(PackageError::InvalidFacility(facility.clone())); + } for (name, dependency) in &self.dependencies { if !valid_package_name(name) { return Err(PackageError::InvalidDependency { @@ -339,6 +352,31 @@ target = "opentrons-ot2" )); } + #[test] + fn reads_the_default_facility_and_holds_it_to_the_target_name_rule() { + let manifest = PackageManifest::parse( + r#"[package] +name = "tet-reporter" +version = "0.1.0" + +[build] +facility = "main-bench" +"#, + ) + .unwrap(); + assert_eq!(manifest.build.facility.as_deref(), Some("main-bench")); + manifest.validate().unwrap(); + + let escaping = PackageManifest::parse( + "[package]\nname = \"t\"\nversion = \"0.1.0\"\n\n[build]\nfacility = \"../elsewhere\"\n", + ) + .unwrap(); + assert!(matches!( + escaping.validate(), + Err(PackageError::InvalidFacility(_)) + )); + } + #[test] fn reads_the_inventory_a_target_build_resolves_against() { let manifest = PackageManifest::parse( diff --git a/crates/lab-package/src/package.rs b/crates/lab-package/src/package.rs index 112c3e5..830eb44 100644 --- a/crates/lab-package/src/package.rs +++ b/crates/lab-package/src/package.rs @@ -103,6 +103,10 @@ pub enum PackageError { "invalid default target '{0}'; a target names a profile under 'targets/' using letters, digits, '-' or '_'" )] InvalidTarget(String), + #[error( + "invalid default facility '{0}'; a facility names a file under 'facilities/' using letters, digits, '-' or '_'" + )] + InvalidFacility(String), #[error("package '{package}' has no Lab source modules under {source_root}")] NoSources { package: String, diff --git a/crates/lab-runfmt/Cargo.toml b/crates/lab-runfmt/Cargo.toml new file mode 100644 index 0000000..7954fd7 --- /dev/null +++ b/crates/lab-runfmt/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "lab-runfmt" +description = "Run-document formats shared by Lab's emitters and runners" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +lab-instruments = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +tempfile = "3" + +[lints] +workspace = true diff --git a/crates/lab-compiler/src/runfmt/mod.rs b/crates/lab-runfmt/src/lib.rs similarity index 62% rename from crates/lab-compiler/src/runfmt/mod.rs rename to crates/lab-runfmt/src/lib.rs index e1b924b..1948a18 100644 --- a/crates/lab-compiler/src/runfmt/mod.rs +++ b/crates/lab-runfmt/src/lib.rs @@ -1,10 +1,16 @@ //! Run-document formats: the schemas shared between the emitters that write -//! executable artifacts and the runner that replays them. +//! executable artifacts and the runners that replay them. //! //! Every format here is a reviewed execution boundary: the document is what -//! an operator approves, and the runner adds nothing but ids, timing, and +//! an operator approves, and a runner adds nothing but ids, timing, and //! confirmations. Each format is versioned by its `format` string; a change //! to what a document means is a new format version, not an edit. +//! +//! Every interpreter of these documents loads them through the checked +//! loaders in this crate, so a wrong or missing format string fails the same +//! way everywhere. + +use std::path::Path; use serde::{Deserialize, Serialize}; @@ -20,6 +26,87 @@ pub const PLATE_READ_FORMAT: &str = "lab.plate-read.v0"; /// The format string every `lab.workcell-run.v0` document declares. pub const WORKCELL_RUN_FORMAT: &str = "lab.workcell-run.v0"; +/// The file name a wave directory's coordination plan is stored under. +pub const WORKCELL_PLAN_FILE: &str = "plan.workcell.json"; + +/// Why a run document failed to load. +#[derive(Debug, thiserror::Error)] +pub enum RunDocumentError { + #[error("cannot read {path}: {source}")] + Io { + path: String, + #[source] + source: std::io::Error, + }, + #[error("{path} is not a valid document: {source}")] + Parse { + path: String, + #[source] + source: serde_json::Error, + }, + #[error("{path} declares format '{found}', but this reader expects '{expected}'")] + WrongFormat { + path: String, + expected: &'static str, + found: String, + }, +} + +fn load_document(path: &Path) -> Result +where + T: serde::de::DeserializeOwned, +{ + let text = std::fs::read_to_string(path).map_err(|source| RunDocumentError::Io { + path: path.display().to_string(), + source, + })?; + serde_json::from_str(&text).map_err(|source| RunDocumentError::Parse { + path: path.display().to_string(), + source, + }) +} + +fn check_format(path: &Path, expected: &'static str, found: &str) -> Result<(), RunDocumentError> { + if found == expected { + Ok(()) + } else { + Err(RunDocumentError::WrongFormat { + path: path.display().to_string(), + expected, + found: found.to_string(), + }) + } +} + +/// Load and format-check one `lab.star-run.v0` document. +pub fn load_star_run(path: &Path) -> Result { + let document: StarRunDocument = load_document(path)?; + check_format(path, STAR_RUN_FORMAT, &document.format)?; + Ok(document) +} + +/// Load and format-check one `lab.thermocycle-run.v0` document. +pub fn load_thermocycle(path: &Path) -> Result { + let document: ThermocycleRunDocument = load_document(path)?; + check_format(path, THERMOCYCLE_RUN_FORMAT, &document.format)?; + Ok(document) +} + +/// Load and format-check one `lab.plate-read.v0` document. +pub fn load_plate_read(path: &Path) -> Result { + let document: PlateReadDocument = load_document(path)?; + check_format(path, PLATE_READ_FORMAT, &document.format)?; + Ok(document) +} + +/// Load and format-check the coordination plan in a wave directory. +pub fn load_workcell_plan(directory: &Path) -> Result { + let path = directory.join(WORKCELL_PLAN_FILE); + let document: WorkcellRunDocument = load_document(&path)?; + check_format(&path, WORKCELL_RUN_FORMAT, &document.format)?; + Ok(document) +} + /// One `lab.thermocycle-run.v0` document: a device-neutral thermal program /// for one plate. The station's kind decides which instrument executes it; /// the document never names a vendor. @@ -191,4 +278,33 @@ mod tests { "absent manual steps mean none" ); } + + #[test] + fn a_loader_rejects_a_document_with_the_wrong_format() { + let directory = tempfile::tempdir().expect("the test directory is creatable"); + let path = directory.path().join("wrong.star.json"); + std::fs::write( + &path, + r#"{ "format": "lab.star-run.v99", "run": "r", "title": "t", + "machine": "STAR", "channels": 8, "steps": [] }"#, + ) + .expect("the fixture writes"); + let error = load_star_run(&path).expect_err("a wrong format string is rejected"); + assert!( + matches!(error, RunDocumentError::WrongFormat { expected, .. } if expected == STAR_RUN_FORMAT), + "the error names the expected format: {error}" + ); + } + + #[test] + fn the_workcell_plan_loader_reads_from_its_well_known_file_name() { + let directory = tempfile::tempdir().expect("the test directory is creatable"); + std::fs::write( + directory.path().join(WORKCELL_PLAN_FILE), + r#"{ "format": "lab.workcell-run.v0", "stations": [], "nodes": [] }"#, + ) + .expect("the fixture writes"); + let plan = load_workcell_plan(directory.path()).expect("the plan loads"); + assert!(plan.nodes.is_empty(), "the empty plan round-trips"); + } } diff --git a/crates/lab-runtime/Cargo.toml b/crates/lab-runtime/Cargo.toml new file mode 100644 index 0000000..1d7c681 --- /dev/null +++ b/crates/lab-runtime/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "lab-runtime" +description = "Interpreters for Lab's run documents: live execution and simulation" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[features] +# Live hardware access: USB transport for the Hamilton STAR. Simulation and +# dry runs never need this, so a pure-simulation build links no libusb. +hardware = ["hamilton-star/usb"] + +[dependencies] +anyhow = { workspace = true } +hamilton-star = { workspace = true } +lab-instruments = { workspace = true } +lab-runfmt = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +toml = { workspace = true } + +[dev-dependencies] +tempfile = "3" + +[lints] +workspace = true diff --git a/crates/lab-runtime/src/clock.rs b/crates/lab-runtime/src/clock.rs new file mode 100644 index 0000000..3dd5aed --- /dev/null +++ b/crates/lab-runtime/src/clock.rs @@ -0,0 +1,72 @@ +//! The clock port: live runs read the wall clock, simulated runs advance a +//! virtual one. + +use std::time::{SystemTime, UNIX_EPOCH}; + +pub trait Clock { + /// Seconds since the Unix epoch, as the ledger records them. + fn now_unix(&self) -> u64; +} + +/// The wall clock the live runner stamps ledger entries with. +pub struct WallClock; + +impl Clock for WallClock { + fn now_unix(&self) -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0) + } +} + +/// A clock that only moves when the simulation says so. Time starts at an +/// origin and accumulates fractional seconds as simulated work completes. +#[derive(Clone, Debug, Default)] +pub struct VirtualClock { + origin_unix: u64, + elapsed_seconds: f64, +} + +impl VirtualClock { + pub fn new(origin_unix: u64) -> Self { + Self { + origin_unix, + elapsed_seconds: 0.0, + } + } + + /// Moves the clock forward. Negative durations are a programming error + /// and are ignored rather than rewinding recorded history. + pub fn advance(&mut self, seconds: f64) { + if seconds > 0.0 { + self.elapsed_seconds += seconds; + } + } + + /// Seconds elapsed since the simulation began. + pub fn elapsed_seconds(&self) -> f64 { + self.elapsed_seconds + } +} + +impl Clock for VirtualClock { + fn now_unix(&self) -> u64 { + self.origin_unix + self.elapsed_seconds as u64 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_virtual_clock_accumulates_and_never_rewinds() { + let mut clock = VirtualClock::new(1_000); + clock.advance(90.0); + clock.advance(0.5); + clock.advance(-30.0); + assert_eq!(clock.elapsed_seconds(), 90.5); + assert_eq!(clock.now_unix(), 1_090); + } +} diff --git a/crates/lab-runtime/src/durations.rs b/crates/lab-runtime/src/durations.rs new file mode 100644 index 0000000..6cfedcc --- /dev/null +++ b/crates/lab-runtime/src/durations.rs @@ -0,0 +1,177 @@ +//! The time model a simulation runs on. +//! +//! Thermal durations are computed exactly from the profile a document +//! carries: ramps at the stated or device-maximum rate, plus holds, across +//! every repeat. Everything else is an estimate this model states as data: +//! per-frame costs for STAR commands and human times for handoffs and +//! manual steps. Estimates are serializable so measured run ledgers can +//! calibrate them later; nothing here pretends to more precision than it +//! has. + +use std::collections::BTreeMap; + +use lab_instruments::{ThermalLimits, ThermalProfile}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct DurationModel { + /// The model's name, recorded in every trace it produces. + pub name: String, + /// Block temperature assumed before the first thermal program. + pub ambient_celsius: f64, + /// A human carries labware between stations and confirms. + pub handoff_seconds: f64, + /// A human performs a non-movement step and confirms. + pub manual_seconds: f64, + /// A motorized door opens or closes. + pub door_seconds: f64, + /// A STAR frame whose command has no table entry. + pub star_frame_default_seconds: f64, + /// Per-command STAR frame costs, keyed by module and code, e.g. `C0TP`. + pub star_frame_seconds: BTreeMap, +} + +impl Default for DurationModel { + fn default() -> Self { + // Coarse, deliberately conservative estimates. A real bench's run + // ledger is the calibration source; these are starting points. + let star_frame_seconds = BTreeMap::from( + [ + ("C0TT", 0.5), // define a tip type: bookkeeping only + ("C0TP", 8.0), // pick up tips + ("C0TR", 6.0), // discard tips + ("C0AS", 12.0), // aspirate, with liquid seek + ("C0DS", 10.0), // dispense + ("C0ZA", 3.0), // retract to Z-safety + ] + .map(|(code, seconds)| (code.to_string(), seconds)), + ); + Self { + name: "default-v0".to_string(), + ambient_celsius: 25.0, + handoff_seconds: 90.0, + manual_seconds: 180.0, + door_seconds: 5.0, + star_frame_default_seconds: 5.0, + star_frame_seconds, + } + } +} + +impl DurationModel { + /// The cost of one STAR frame, from the table or the stated default. + pub fn star_frame_seconds(&self, module: &str, code: &str) -> f64 { + let key = format!("{module}{code}"); + self.star_frame_seconds + .get(&key) + .copied() + .unwrap_or(self.star_frame_default_seconds) + } + + /// The exact duration of a thermal profile from a starting block + /// temperature, and the block temperature it ends at. Ramps run at the + /// step's stated rate or the device maximum; holds are as written; + /// repeats carry the block temperature across iterations. + pub fn thermal_profile_seconds( + &self, + profile: &ThermalProfile, + limits: &ThermalLimits, + starting_celsius: f64, + ) -> (f64, f64) { + let mut seconds = 0.0; + let mut block = starting_celsius; + for stage in &profile.stages { + for _ in 0..stage.repeats { + for step in &stage.steps { + let rate = step + .ramp_c_per_s + .filter(|rate| *rate > 0.0) + .unwrap_or(limits.ramp_max_c_per_s); + seconds += (step.celsius - block).abs() / rate; + seconds += step.hold_seconds; + block = step.celsius; + } + } + } + (seconds, block) + } + + /// The duration of a hold command: one ramp to the target. + pub fn thermal_ramp_seconds( + &self, + limits: &ThermalLimits, + from_celsius: f64, + to_celsius: f64, + ) -> f64 { + (to_celsius - from_celsius).abs() / limits.ramp_max_c_per_s + } +} + +#[cfg(test)] +mod tests { + use super::*; + use lab_instruments::{ThermalStage, ThermalStep}; + + fn odtc_like_limits() -> ThermalLimits { + ThermalLimits { + block_min_celsius: 4.0, + block_max_celsius: 99.0, + lid_min_celsius: 30.0, + lid_max_celsius: 115.0, + ramp_max_c_per_s: 4.4, + per_step_lid: true, + } + } + + fn step(celsius: f64, hold_seconds: f64) -> ThermalStep { + ThermalStep { + celsius, + hold_seconds, + ramp_c_per_s: None, + lid_celsius: None, + } + } + + #[test] + fn a_golden_gate_profile_computes_exactly() { + // 30 cycles of 37 °C / 90 s + 16 °C / 180 s, then 60 °C / 300 s, + // from 25 °C ambient at the 4.4 °C/s device maximum. + let profile = ThermalProfile { + stages: vec![ + ThermalStage { + steps: vec![step(37.0, 90.0), step(16.0, 180.0)], + repeats: 30, + }, + ThermalStage { + steps: vec![step(60.0, 300.0)], + repeats: 1, + }, + ], + }; + let model = DurationModel::default(); + let (seconds, final_celsius) = + model.thermal_profile_seconds(&profile, &odtc_like_limits(), 25.0); + + // Holds: 30 * (90 + 180) + 300 = 8400 s. + // Ramps: 25→37 once (12/4.4), then 37↔16 across the cycles: 21 °C + // each way, 59 crossings (30 down, 29 back up), then 16→60 (44). + let ramp = (12.0 + 59.0 * 21.0 + 44.0) / 4.4; + assert!( + (seconds - (8400.0 + ramp)).abs() < 1e-9, + "expected {} got {seconds}", + 8400.0 + ramp + ); + assert_eq!(final_celsius, 60.0, "the block ends at the last plateau"); + } + + #[test] + fn frame_costs_come_from_the_table_with_a_stated_default() { + let model = DurationModel::default(); + assert_eq!(model.star_frame_seconds("C0", "TP"), 8.0); + assert_eq!( + model.star_frame_seconds("C0", "XX"), + model.star_frame_default_seconds + ); + } +} diff --git a/crates/lab-runtime/src/events.rs b/crates/lab-runtime/src/events.rs new file mode 100644 index 0000000..8dc5dc6 --- /dev/null +++ b/crates/lab-runtime/src/events.rs @@ -0,0 +1,163 @@ +//! The event port: everything a run walk has to say goes through one sink. +//! The live runner's sink prints human narration; the simulator's sink +//! stamps virtual time on each event and accumulates the trace. + +use serde::{Deserialize, Serialize}; + +/// One observable moment in a run walk. Events carry facts, not phrasing; +/// each sink decides how to present them. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "event", rename_all = "kebab-case")] +pub enum RunEvent { + /// The walk is about to start `pending` nodes, skipping `completed`. + Planned { + pending: usize, + completed: usize, + }, + Connecting { + station: String, + detail: String, + }, + Connected { + station: String, + }, + NodeStarted { + id: String, + }, + NodeSkipped { + id: String, + }, + NodeCompleted { + id: String, + }, + /// A station program began: a STAR frame sequence or a thermal profile. + ProgramStarted { + station: String, + title: String, + #[serde(flatten)] + extent: ProgramExtent, + }, + /// One STAR frame is about to execute. When the frame carries deck + /// coordinates, the first channel's target rides along so a viewer + /// can move a pipetting head to where the work happens. + Frame { + station: String, + index: usize, + description: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + x_mm: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + y_mm: Option, + }, + /// The thermal profile is running to completion on its station. + ThermalRunning { + station: String, + }, + ThermalWarning { + station: String, + warning: String, + }, + /// The block holds a temperature until retrieval. + ThermalHold { + station: String, + celsius: f64, + }, + DoorOpened { + station: String, + }, + DoorClosed { + station: String, + }, + /// The operator is needed, starting now. + AttentionRequired { + node: String, + prompt: String, + }, + /// The operator's step is done; the walk is unattended again. + AttentionReleased { + node: String, + }, + /// Labware physically moved between stations. + LabwareMoved { + labware: String, + from: String, + to: String, + }, +} + +/// How large a station program is, in the unit the station thinks in. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ProgramExtent { + Frames { + frames: usize, + }, + Plateaus { + plateaus: usize, + #[serde(default, skip_serializing_if = "Option::is_none")] + final_hold_celsius: Option, + }, +} + +pub trait EventSink { + fn emit(&mut self, event: RunEvent); +} + +/// The first channel's deck target in a STAR firmware frame, in +/// millimeters, when the frame carries `xp`/`yp` position parameters. +/// Purely observational: the machine plans nothing from this. +pub fn frame_position(frame: &str) -> Option<(f64, f64)> { + fn parameter(frame: &str, key: &str) -> Option { + let start = frame.find(key)? + key.len(); + let digits: String = frame[start..] + .chars() + .take_while(char::is_ascii_digit) + .collect(); + if digits.is_empty() { + return None; + } + // Firmware positions are 0.1 mm units. + Some(digits.parse::().ok()? / 10.0) + } + Some((parameter(frame, "xp")?, parameter(frame, "yp")?)) +} + +/// A sink that discards everything, for tests that only assert outcomes. +pub struct NullSink; + +impl EventSink for NullSink { + fn emit(&mut self, _event: RunEvent) {} +} + +/// A sink that keeps every event, for tests that assert the walk's shape. +#[derive(Default)] +pub struct RecordingSink { + pub events: Vec, +} + +impl EventSink for RecordingSink { + fn emit(&mut self, event: RunEvent) { + self.events.push(event); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_pickup_frame_yields_its_first_channel_target() { + let frame = "C0TPxp01179 01179 00000&yp2418 2328 0000&tm1 1 0&tt01tp2244tz2164th2450td0"; + assert_eq!(frame_position(frame), Some((117.9, 241.8))); + } + + #[test] + fn a_frame_without_positions_yields_none() { + assert_eq!(frame_position("C0ZA"), None); + assert_eq!( + frame_position("C0TTtt00tf1tl0519tv03600tg2tu0"), + None, + "tip definitions carry no deck target" + ); + } +} diff --git a/crates/lab-runtime/src/facility.rs b/crates/lab-runtime/src/facility.rs new file mode 100644 index 0000000..2b30179 --- /dev/null +++ b/crates/lab-runtime/src/facility.rs @@ -0,0 +1,348 @@ +//! A facility describes a lab as it stands: the stations on its benches, +//! the storage that holds its stock, its consumables, and how labware +//! travels between stations. +//! +//! One facility serves every package that runs in that lab, and one package +//! can be simulated against several candidate facilities — the relationship +//! is many-to-many, so the description lives in its own file under +//! `facilities/` and a manifest carries at most a pointer to a default. +//! Station addresses stay runtime input, exactly as they are for `lab run`. + +use std::collections::BTreeSet; +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +/// The directory a package's facility files live under. +pub const FACILITY_DIR: &str = "facilities"; + +#[derive(Debug, thiserror::Error)] +pub enum FacilityError { + #[error("cannot read {path}: {source}")] + Io { + path: String, + #[source] + source: std::io::Error, + }, + #[error("{path} is not a valid facility description: {source}")] + Parse { + path: String, + #[source] + source: toml::de::Error, + }, + #[error("facility '{facility}' declares station '{station}' twice")] + DuplicateStation { facility: String, station: String }, + #[error("facility '{facility}' declares storage '{storage}' twice")] + DuplicateStorage { facility: String, storage: String }, + #[error( + "facility '{facility}' transport is '{found}'; this runtime supports 'human' transport only" + )] + UnsupportedTransport { facility: String, found: String }, + #[error( + "facility '{facility}' walks between stations in {seconds} s; travel time must be positive" + )] + NonPositiveWalk { facility: String, seconds: f64 }, + #[error( + "the plan needs station '{station}' of kind '{kind}', which facility '{facility}' does not have{hint}" + )] + MissingStation { + facility: String, + station: String, + kind: String, + hint: String, + }, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Facility { + pub facility: FacilityMetadata, + #[serde(default, rename = "station")] + pub stations: Vec, + #[serde(default, rename = "storage")] + pub storage: Vec, + #[serde(default, rename = "consumable")] + pub consumables: Vec, + #[serde(default)] + pub transport: FacilityTransport, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FacilityMetadata { + pub name: String, +} + +/// One instrument the facility has, in the same vocabulary workcell +/// profiles use for stations. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FacilityStation { + pub name: String, + /// The station kind string, e.g. `hamilton.star` or `inheco.odtc`. + pub kind: String, + /// The target profile this station's bench is described by, when one + /// exists under `targets/`. + #[serde(default)] + pub profile: Option, + /// Where the station answers on this bench; runtime input, never + /// compiled into artifacts. + #[serde(default)] + pub address: Option, +} + +/// One storage location and the stock it holds. Stock is inventory state, +/// named by the symbolic identities source declares. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct StorageUnit { + pub name: String, + /// `fridge`, `freezer`, or `shelf`. + pub kind: String, + #[serde(default)] + pub temperature_c: Option, + #[serde(default)] + pub materials: BTreeSet, + #[serde(default)] + pub artifacts: BTreeSet, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Consumable { + /// The labware catalog id, e.g. `tip_rack_300ul`. + pub labware: String, + pub count: u32, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, default)] +pub struct FacilityTransport { + /// How labware moves between stations. `human` is the only supported + /// mode; an arm is a future station kind, not a transport string. + pub between: String, + /// How long one human handoff takes in this facility, door to door: + /// seal, carry, seat, confirm. + pub walk_seconds: f64, +} + +impl Default for FacilityTransport { + fn default() -> Self { + Self { + between: "human".to_string(), + walk_seconds: 90.0, + } + } +} + +impl Facility { + pub fn parse(path_label: &str, text: &str) -> Result { + let facility: Facility = toml::from_str(text).map_err(|source| FacilityError::Parse { + path: path_label.to_string(), + source, + })?; + facility.validate()?; + Ok(facility) + } + + fn validate(&self) -> Result<(), FacilityError> { + let mut station_names = BTreeSet::new(); + for station in &self.stations { + if !station_names.insert(station.name.as_str()) { + return Err(FacilityError::DuplicateStation { + facility: self.facility.name.clone(), + station: station.name.clone(), + }); + } + } + let mut storage_names = BTreeSet::new(); + for storage in &self.storage { + if !storage_names.insert(storage.name.as_str()) { + return Err(FacilityError::DuplicateStorage { + facility: self.facility.name.clone(), + storage: storage.name.clone(), + }); + } + } + if self.transport.between != "human" { + return Err(FacilityError::UnsupportedTransport { + facility: self.facility.name.clone(), + found: self.transport.between.clone(), + }); + } + if self.transport.walk_seconds <= 0.0 { + return Err(FacilityError::NonPositiveWalk { + facility: self.facility.name.clone(), + seconds: self.transport.walk_seconds, + }); + } + Ok(()) + } + + pub fn station(&self, name: &str) -> Option<&FacilityStation> { + self.stations.iter().find(|station| station.name == name) + } + + /// Every material stocked anywhere in the facility. + pub fn stocked_materials(&self) -> BTreeSet { + self.storage + .iter() + .flat_map(|unit| unit.materials.iter().cloned()) + .collect() + } + + /// Every artifact held anywhere in the facility. + pub fn stocked_artifacts(&self) -> BTreeSet { + self.storage + .iter() + .flat_map(|unit| unit.artifacts.iter().cloned()) + .collect() + } + + /// Checks that every station a plan needs exists here, by name and + /// kind. A near-miss names the kind mismatch instead of a bare absence. + pub fn check_stations( + &self, + required: &[lab_runfmt::WorkcellStation], + ) -> Result<(), FacilityError> { + for needed in required { + match self.station(&needed.name) { + Some(station) if station.kind == needed.kind => {} + Some(station) => { + return Err(FacilityError::MissingStation { + facility: self.facility.name.clone(), + station: needed.name.clone(), + kind: needed.kind.clone(), + hint: format!("; its station '{}' is a '{}'", station.name, station.kind), + }); + } + None => { + return Err(FacilityError::MissingStation { + facility: self.facility.name.clone(), + station: needed.name.clone(), + kind: needed.kind.clone(), + hint: String::new(), + }); + } + } + } + Ok(()) + } +} + +/// Loads and validates one facility file. +pub fn load_facility(path: &Path) -> Result { + let text = std::fs::read_to_string(path).map_err(|source| FacilityError::Io { + path: path.display().to_string(), + source, + })?; + Facility::parse(&path.display().to_string(), &text) +} + +#[cfg(test)] +mod tests { + use super::*; + + const MAIN_BENCH: &str = r#" +[facility] +name = "main-bench" + +[[station]] +name = "star-1" +kind = "hamilton.star" +profile = "hamilton-star" + +[[station]] +name = "odtc-1" +kind = "inheco.odtc" +address = "169.254.10.40:8080" + +[[storage]] +name = "fridge-a" +kind = "fridge" +temperature_c = 4.0 +materials = ["assembly_mix", "competent_cells"] + +[[consumable]] +labware = "tip_rack_300ul" +count = 12 + +[transport] +between = "human" +walk_seconds = 45.0 +"#; + + #[test] + fn a_facility_parses_and_answers_stock_and_station_questions() { + let facility = Facility::parse("main-bench.toml", MAIN_BENCH).unwrap(); + assert_eq!(facility.facility.name, "main-bench"); + assert_eq!(facility.station("odtc-1").unwrap().kind, "inheco.odtc"); + assert!(facility.stocked_materials().contains("assembly_mix")); + assert_eq!(facility.transport.walk_seconds, 45.0); + + let plan_stations = vec![ + lab_runfmt::WorkcellStation { + name: "star-1".to_string(), + kind: "hamilton.star".to_string(), + program_dir: "stations/star-1".to_string(), + }, + lab_runfmt::WorkcellStation { + name: "odtc-1".to_string(), + kind: "inheco.odtc".to_string(), + program_dir: "stations/odtc-1".to_string(), + }, + ]; + facility.check_stations(&plan_stations).unwrap(); + + let elsewhere = vec![lab_runfmt::WorkcellStation { + name: "reader-1".to_string(), + kind: "byonoy.absorbance96".to_string(), + program_dir: "stations/reader-1".to_string(), + }]; + let error = facility.check_stations(&elsewhere).unwrap_err(); + assert!( + error.to_string().contains("reader-1"), + "the missing station is named: {error}" + ); + } + + #[test] + fn a_kind_mismatch_is_named_rather_than_reported_as_absence() { + let facility = Facility::parse("main-bench.toml", MAIN_BENCH).unwrap(); + let mismatched = vec![lab_runfmt::WorkcellStation { + name: "odtc-1".to_string(), + kind: "byonoy.absorbance96".to_string(), + program_dir: "stations/odtc-1".to_string(), + }]; + let error = facility.check_stations(&mismatched).unwrap_err(); + assert!( + error.to_string().contains("is a 'inheco.odtc'"), + "the near-miss names the actual kind: {error}" + ); + } + + #[test] + fn validation_rejects_duplicates_and_unknown_transport() { + let duplicated = + format!("{MAIN_BENCH}\n[[station]]\nname = \"star-1\"\nkind = \"hamilton.star\"\n"); + assert!(matches!( + Facility::parse("f.toml", &duplicated), + Err(FacilityError::DuplicateStation { .. }) + )); + + let arm = MAIN_BENCH.replace("between = \"human\"", "between = \"arm\""); + assert!(matches!( + Facility::parse("f.toml", &arm), + Err(FacilityError::UnsupportedTransport { .. }) + )); + } + + #[test] + fn a_misspelled_key_must_not_silently_vanish() { + let misspelled = MAIN_BENCH.replace("materials =", "material ="); + assert!(matches!( + Facility::parse("f.toml", &misspelled), + Err(FacilityError::Parse { .. }) + )); + } +} diff --git a/crates/lab-runtime/src/ledger.rs b/crates/lab-runtime/src/ledger.rs new file mode 100644 index 0000000..24d5b4d --- /dev/null +++ b/crates/lab-runtime/src/ledger.rs @@ -0,0 +1,130 @@ +//! The durable run ledger a workcell wave accumulates beside its plan. +//! +//! The ledger is the run's memory and its evidence: which nodes completed, +//! when, and on whose confirmation. Only live runs write it; a simulation +//! records a trace instead, so a simulated wave never blocks a real one +//! from starting fresh. + +use std::collections::BTreeSet; +use std::fs; +use std::io::Write as _; +use std::path::Path; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +use crate::clock::Clock; + +/// The ledger file a wave accumulates beside its plan. +pub const LEDGER_FILE: &str = "run-ledger.jsonl"; + +/// One appended ledger record. +#[derive(Debug, Serialize, Deserialize)] +pub struct LedgerEntry { + pub node: String, + pub event: LedgerEvent, + /// Wall-clock seconds since the Unix epoch. + pub at_unix_seconds: u64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum LedgerEvent { + Started, + Completed, + Failed, +} + +/// Appends one entry; every event is durable before the walk continues. +pub fn append_ledger( + directory: &Path, + node: &str, + event: LedgerEvent, + clock: &dyn Clock, +) -> Result<()> { + let entry = LedgerEntry { + node: node.to_string(), + event, + at_unix_seconds: clock.now_unix(), + }; + let mut line = serde_json::to_string(&entry)?; + line.push('\n'); + let path = directory.join(LEDGER_FILE); + let mut file = fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .with_context(|| format!("failed to open {}", path.display()))?; + file.write_all(line.as_bytes()) + .with_context(|| format!("failed to append to {}", path.display()))?; + Ok(()) +} + +/// The node ids the ledger records as completed. +pub fn completed_nodes(directory: &Path) -> Result> { + let path = directory.join(LEDGER_FILE); + if !path.is_file() { + return Ok(BTreeSet::new()); + } + let text = + fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; + let mut completed = BTreeSet::new(); + for (number, line) in text.lines().enumerate() { + if line.trim().is_empty() { + continue; + } + let entry: LedgerEntry = serde_json::from_str(line).with_context(|| { + format!( + "{} line {} is not a ledger entry", + path.display(), + number + 1 + ) + })?; + if entry.event == LedgerEvent::Completed { + completed.insert(entry.node); + } + } + Ok(completed) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::clock::WallClock; + + #[test] + fn the_ledger_round_trips_and_reports_completed_nodes() { + let directory = tempfile::tempdir().unwrap(); + let clock = WallClock; + append_ledger( + directory.path(), + "assembly_run", + LedgerEvent::Started, + &clock, + ) + .unwrap(); + append_ledger( + directory.path(), + "assembly_run", + LedgerEvent::Completed, + &clock, + ) + .unwrap(); + append_ledger( + directory.path(), + "assembly_thermocycle", + LedgerEvent::Started, + &clock, + ) + .unwrap(); + let completed = completed_nodes(directory.path()).unwrap(); + assert!( + completed.contains("assembly_run"), + "a completed node is remembered" + ); + assert!( + !completed.contains("assembly_thermocycle"), + "a started-but-unfinished node is not skipped on resume" + ); + } +} diff --git a/crates/lab-runtime/src/lib.rs b/crates/lab-runtime/src/lib.rs new file mode 100644 index 0000000..cad9dd7 --- /dev/null +++ b/crates/lab-runtime/src/lib.rs @@ -0,0 +1,33 @@ +//! Interpreters for Lab's run documents. +//! +//! A run document (see `lab-runfmt`) is a reviewed execution boundary, and +//! everything in this crate interprets those documents without ever +//! planning or deriving new work. Three interpreters share one node walk: +//! +//! - **live execution** (`lab run`) drives real stations on a wall clock; +//! - **dry run** validates every document and narrates the walk; +//! - **simulation** (`lab simulate`) drives simulated stations on a +//! virtual clock and records a `lab.sim-trace.v0` trace. +//! +//! The walk is parameterized over four ports: a [`clock::Clock`], an +//! [`operator::Operator`] for confirmations, an [`events::EventSink`] for +//! narration and traces, and a [`stations::Connector`] that opens station +//! sessions. The live and simulated interpreters differ only in which +//! implementations they plug in. + +pub mod clock; +pub mod durations; +pub mod events; +pub mod facility; +pub mod ledger; +pub mod operator; +pub mod simulate; +pub mod star; +pub mod stations; +pub mod trace; +pub mod workcell; + +#[cfg(test)] +pub(crate) mod testing; + +pub use hamilton_star; diff --git a/crates/lab-runtime/src/operator.rs b/crates/lab-runtime/src/operator.rs new file mode 100644 index 0000000..3c433d2 --- /dev/null +++ b/crates/lab-runtime/src/operator.rs @@ -0,0 +1,50 @@ +//! The operator port: every confirmation in a run flows through one +//! interface, so the live runner asks a human at the terminal and the +//! simulator answers for a modeled one. + +use std::io::{BufRead, Write}; + +use anyhow::Result; + +/// What a confirmation is for. The live operator sees the same prompt +/// either way; a simulated operator charges different time for different +/// kinds of step. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ConfirmKind { + /// The gate before any motion starts. + PreRun, + /// A labware movement between stations. + Handoff, + /// A by-hand step that is not a movement. + Manual, +} + +pub trait Operator { + fn confirm(&mut self, kind: ConfirmKind, prompt: &str) -> Result; +} + +/// The terminal operator: prints the prompt and reads one line. `y`, `Y`, +/// and `yes` confirm; anything else declines. +pub struct StdinOperator; + +impl Operator for StdinOperator { + fn confirm(&mut self, _kind: ConfirmKind, prompt: &str) -> Result { + print!("{prompt}"); + std::io::stdout().flush()?; + let mut answer = String::new(); + std::io::stdin().lock().read_line(&mut answer)?; + Ok(matches!(answer.trim(), "y" | "Y" | "yes")) + } +} + +/// An operator that always answers the same way. The simulator confirms +/// every step with it; tests decline with it. +pub struct AutoOperator { + pub answer: bool, +} + +impl Operator for AutoOperator { + fn confirm(&mut self, _kind: ConfirmKind, _prompt: &str) -> Result { + Ok(self.answer) + } +} diff --git a/crates/lab-runtime/src/simulate.rs b/crates/lab-runtime/src/simulate.rs new file mode 100644 index 0000000..5586cdd --- /dev/null +++ b/crates/lab-runtime/src/simulate.rs @@ -0,0 +1,266 @@ +//! The simulation interpreter: the same walk the live runner performs, +//! over simulated stations, a modeled operator, and a virtual clock. +//! +//! Simulation writes no ledger — the ledger is evidence of physical work, +//! and none happened. Its record is the trace. + +use std::cell::RefCell; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; +use std::rc::Rc; + +use anyhow::{Result, bail}; + +use crate::clock::VirtualClock; +use crate::durations::DurationModel; +use crate::events::{EventSink, ProgramExtent, RunEvent}; +use crate::operator::{ConfirmKind, Operator}; +use crate::stations::Sessions; +use crate::stations::sim::{SharedClock, SimConnector}; +use crate::trace::{SIM_TRACE_FORMAT, SimTraceDocument, TraceSink, summarize}; +use crate::workcell::{Bench, LoadedWorkcell, NodeRun, execute_node}; + +/// How a simulation is configured. +pub struct SimulationConfig { + /// The wall time the virtual clock starts at, for ledger-comparable + /// timestamps in reports. Timing inside the trace is relative seconds. + pub origin_unix: u64, + pub durations: DurationModel, +} + +/// The operator a simulation models: confirms everything, and charges the +/// modeled human time for each kind of step. +struct SimOperator { + clock: SharedClock, + durations: Rc, +} + +impl Operator for SimOperator { + fn confirm(&mut self, kind: ConfirmKind, _prompt: &str) -> Result { + let seconds = match kind { + ConfirmKind::PreRun => 0.0, + ConfirmKind::Handoff => self.durations.handoff_seconds, + ConfirmKind::Manual => self.durations.manual_seconds, + }; + self.clock.borrow_mut().advance(seconds); + Ok(true) + } +} + +/// Simulates one workcell wave and returns its trace. +/// +/// The plan's `after` edges are asserted as the walk proceeds: today's +/// emitters produce a linear chain, and a future DAG plan must fail loudly +/// here rather than be silently walked in document order. +pub fn simulate_workcell( + loaded: &LoadedWorkcell, + config: SimulationConfig, +) -> Result { + let clock: SharedClock = Rc::new(RefCell::new(VirtualClock::new(config.origin_unix))); + let durations = Rc::new(config.durations); + let bench = Bench { + thermocycler_station: loaded.thermocycler_station.clone(), + addresses: BTreeMap::new(), + }; + let mut connector = SimConnector::new(clock.clone(), durations.clone()); + let mut operator = SimOperator { + clock: clock.clone(), + durations: durations.clone(), + }; + let mut sink = TraceSink::new(clock.clone()); + let mut sessions = Sessions::new(&mut connector); + + let mut done: BTreeSet<&str> = BTreeSet::new(); + for node in &loaded.nodes { + for dependency in &node.after { + if !done.contains(dependency.as_str()) { + bail!( + "node '{}' depends on '{dependency}', which has not run; this plan is not the linear chain this simulator walks — simulating dependency graphs is not supported yet", + node.id + ); + } + } + sink.emit(RunEvent::NodeStarted { + id: node.id.clone(), + }); + match execute_node(node, &mut sessions, &bench, &mut operator, &mut sink)? { + NodeRun::Done => {} + NodeRun::Declined => bail!("the simulated operator declined; this cannot happen"), + } + sink.emit(RunEvent::NodeCompleted { + id: node.id.clone(), + }); + done.insert(node.id.as_str()); + } + + let total_seconds = clock.borrow().elapsed_seconds(); + let summary = summarize(&sink.events, total_seconds); + Ok(SimTraceDocument { + format: SIM_TRACE_FORMAT.to_string(), + plan: lab_runfmt::WORKCELL_PLAN_FILE.to_string(), + durations: durations.name.clone(), + events: sink.events, + summary, + }) +} + +/// Simulates a single-station Hamilton STAR package: every run document's +/// frames at their modeled cost, with the manual steps that follow each +/// run charged as attended time. +pub fn simulate_star_package( + directory: &Path, + config: SimulationConfig, +) -> Result { + let (runs, _autoload_park_track) = crate::star::load_run_directory(directory)?; + let clock: SharedClock = Rc::new(RefCell::new(VirtualClock::new(config.origin_unix))); + let durations = Rc::new(config.durations); + let mut sink = TraceSink::new(clock.clone()); + + for run in &runs { + sink.emit(RunEvent::NodeStarted { id: run.id.clone() }); + sink.emit(RunEvent::ProgramStarted { + station: "hamilton.star".to_string(), + title: run.title.clone(), + extent: ProgramExtent::Frames { + frames: run.steps.len(), + }, + }); + for (index, step) in run.steps.iter().enumerate() { + let position = crate::events::frame_position(step.command.frame()); + sink.emit(RunEvent::Frame { + station: "hamilton.star".to_string(), + index: index + 1, + description: step.description.clone(), + x_mm: position.map(|(x, _)| x), + y_mm: position.map(|(_, y)| y), + }); + let frame = step.command.frame(); + let module = frame.get(..2).unwrap_or(""); + let seconds = durations.star_frame_seconds(module, step.command.code()); + clock.borrow_mut().advance(seconds); + } + sink.emit(RunEvent::NodeCompleted { id: run.id.clone() }); + for manual in &run.manual_after { + let id = format!("{}.manual", run.id); + sink.emit(RunEvent::NodeStarted { id: id.clone() }); + sink.emit(RunEvent::AttentionRequired { + node: id.clone(), + prompt: format!("{}: {}", manual.title, manual.instructions), + }); + clock.borrow_mut().advance(durations.manual_seconds); + sink.emit(RunEvent::AttentionReleased { node: id.clone() }); + sink.emit(RunEvent::NodeCompleted { id }); + } + } + + let total_seconds = clock.borrow().elapsed_seconds(); + let summary = summarize(&sink.events, total_seconds); + Ok(SimTraceDocument { + format: SIM_TRACE_FORMAT.to_string(), + plan: "automation_manifest.json".to_string(), + durations: durations.name.clone(), + events: sink.events, + summary, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::write_synthetic_wave; + use crate::workcell::load_workcell_directory; + + #[test] + fn a_synthetic_wave_simulates_to_its_exact_modeled_duration() { + let directory = tempfile::tempdir().unwrap(); + write_synthetic_wave(directory.path()); + let loaded = load_workcell_directory(directory.path()).unwrap(); + let trace = simulate_workcell( + &loaded, + SimulationConfig { + origin_unix: 0, + durations: DurationModel::default(), + }, + ) + .unwrap(); + + let model = DurationModel::default(); + // STAR run: one C0TT and one C0ZA frame. + let star = model.star_frame_seconds("C0", "TT") + model.star_frame_seconds("C0", "ZA"); + // Each cycler handoff: door open, human, door close. + let handoff = model.door_seconds + model.handoff_seconds + model.door_seconds; + // Thermal: 25 °C ambient to 37 °C at the 4.4 °C/s maximum, then 90 s. + let thermal = (37.0 - model.ambient_celsius) / 4.4 + 90.0; + let manual = model.manual_seconds; + let expected = star + handoff + thermal + handoff + manual; + assert!( + (trace.summary.total_seconds - expected).abs() < 1e-9, + "expected {expected}, got {}", + trace.summary.total_seconds + ); + + // Attended: two handoffs and the manual step. + let attended = 2.0 * model.handoff_seconds + model.manual_seconds; + assert!( + (trace.summary.attended_seconds - attended).abs() < 1e-9, + "expected {attended} attended, got {}", + trace.summary.attended_seconds + ); + assert_eq!(trace.summary.attention_windows.len(), 3); + assert_eq!(trace.summary.nodes, 5); + assert!( + trace.summary.stations.contains_key("star-1") + && trace.summary.stations.contains_key("odtc-1"), + "both stations report busy time" + ); + + // The trace round-trips as a document. Timestamps may drift by an + // ulp through JSON floats, so equality is structural plus a + // tolerance on the clock. + let text = serde_json::to_string(&trace).unwrap(); + let back: SimTraceDocument = serde_json::from_str(&text).unwrap(); + assert_eq!(back.format, trace.format); + assert_eq!(back.events.len(), trace.events.len()); + assert_eq!(back.summary.nodes, trace.summary.nodes); + assert!((back.summary.total_seconds - trace.summary.total_seconds).abs() < 1e-6); + } + + #[test] + fn simulation_leaves_no_ledger_behind() { + let directory = tempfile::tempdir().unwrap(); + write_synthetic_wave(directory.path()); + let loaded = load_workcell_directory(directory.path()).unwrap(); + simulate_workcell( + &loaded, + SimulationConfig { + origin_unix: 0, + durations: DurationModel::default(), + }, + ) + .unwrap(); + assert!( + !directory.path().join(crate::ledger::LEDGER_FILE).exists(), + "the ledger is evidence of physical work, and none happened" + ); + } + + #[test] + fn an_out_of_order_plan_is_refused() { + let directory = tempfile::tempdir().unwrap(); + write_synthetic_wave(directory.path()); + let mut loaded = load_workcell_directory(directory.path()).unwrap(); + loaded.nodes.swap(0, 4); + let error = simulate_workcell( + &loaded, + SimulationConfig { + origin_unix: 0, + durations: DurationModel::default(), + }, + ) + .expect_err("a broken chain is refused, not silently walked"); + assert!( + error.to_string().contains("depends on"), + "the error names the unmet dependency: {error}" + ); + } +} diff --git a/crates/lab-runtime/src/star.rs b/crates/lab-runtime/src/star.rs new file mode 100644 index 0000000..94471b7 --- /dev/null +++ b/crates/lab-runtime/src/star.rs @@ -0,0 +1,331 @@ +//! Replay of an emitted Hamilton STAR run package. +//! +//! A `lab.star-run.v0` document is reviewed frames; this module loads a +//! package directory, validates every frame through the driver crate, and +//! replays them over an open session. Any firmware error retracts the +//! channels and reports the failed step. The dry-run rendering prints the +//! full step table and touches no hardware. + +use std::fs; +use std::path::Path; +use std::sync::Arc; + +use anyhow::{Context, Result, bail}; +use hamilton_star::{RawCommand, Star, Transport}; +use lab_runfmt::ManualStep; +use serde::Deserialize; + +/// One `lab.star-run.v0` document, loaded and frame-validated. +pub struct LoadedRun { + pub id: String, + pub title: String, + pub steps: Vec, + pub manual_after: Vec, +} + +pub struct LoadedStep { + pub command: RawCommand, + pub description: String, +} + +/// The manifest fields the runner reads: run order and the bench's +/// initialize options. +#[derive(Deserialize)] +struct ManifestSummary { + target: String, + runs: Vec, + deck: ManifestDeck, +} + +#[derive(Deserialize)] +struct ManifestRun { + id: String, +} + +#[derive(Deserialize)] +struct ManifestDeck { + #[serde(default)] + run: ManifestRunOptions, +} + +#[derive(Deserialize, Default)] +struct ManifestRunOptions { + #[serde(default)] + autoload_park_track: Option, +} + +/// Loads a run directory: the automation manifest names the run order, and +/// every document's frames must parse before anything is reported ready. +pub fn load_run_directory(directory: &Path) -> Result<(Vec, Option)> { + let manifest_path = directory.join("automation_manifest.json"); + let manifest_text = fs::read_to_string(&manifest_path).with_context(|| { + format!( + "no automation manifest at {}; point lab run at a directory produced by `lab build` for a hamilton.star target", + manifest_path.display() + ) + })?; + let manifest: ManifestSummary = + serde_json::from_str(&manifest_text).context("failed to parse the automation manifest")?; + if manifest.target != "hamilton.star" { + bail!( + "this package was compiled for '{}'; lab run executes hamilton.star run documents only", + manifest.target + ); + } + + let mut runs = Vec::new(); + for run in &manifest.runs { + let path = directory.join(format!("{}.star.json", run.id)); + let document = lab_runfmt::load_star_run(&path)?; + let steps = document + .steps + .iter() + .map(|step| { + RawCommand::parse(&step.frame) + .map(|command| LoadedStep { + command, + description: step.description.clone(), + }) + .with_context(|| format!("{} carries an unreplayable frame", path.display())) + }) + .collect::>>()?; + runs.push(LoadedRun { + id: document.run, + title: document.title, + steps, + manual_after: document.manual_after, + }); + } + Ok((runs, manifest.deck.run.autoload_park_track)) +} + +/// The outcome of replaying one package. +#[derive(Debug, PartialEq, Eq)] +pub enum RunOutcome { + Completed { + steps: usize, + }, + /// A firmware error stopped the run; the channels were retracted and + /// physical state stands at the named step. + Aborted { + run_id: String, + step_index: usize, + error: String, + }, +} + +/// Replays loaded runs over an open session. `pause` is called between +/// runs with the manual-step text and must return `true` to continue — +/// the operator confirms the bench matches before more motion. +pub fn execute_runs( + star: &Star, + runs: &[LoadedRun], + pause: &mut dyn FnMut(&str) -> bool, + narrate: &mut dyn FnMut(&str), +) -> Result { + let mut executed = 0usize; + for (index, run) in runs.iter().enumerate() { + narrate(&format!( + "run {}: {} ({} steps)", + index + 1, + run.title, + run.steps.len() + )); + for (step_index, step) in run.steps.iter().enumerate() { + narrate(&format!(" [{:>3}] {}", step_index + 1, step.description)); + if let Err(error) = star.execute_raw(&step.command) { + // Any failure leaves the machine mid-motion: retract to + // Z-safety before handing control back. + let retract = RawCommand::parse("C0ZA") + .expect("the retract frame is a constant well-formed frame"); + let _ = star.execute_raw(&retract); + return Ok(RunOutcome::Aborted { + run_id: run.id.clone(), + step_index, + error: error.to_string(), + }); + } + executed += 1; + } + for manual in &run.manual_after { + let prompt = format!("{}: {}", manual.title, manual.instructions); + if !pause(&prompt) { + bail!("run stopped by the operator after '{}'", run.id); + } + } + } + Ok(RunOutcome::Completed { steps: executed }) +} + +/// Renders the dry-run step table: every frame, validated, with the manual +/// steps that follow each run. +pub fn render_dry_run(runs: &[LoadedRun]) -> String { + use std::fmt::Write; + let total_steps: usize = runs.iter().map(|run| run.steps.len()).sum(); + let mut human = format!( + "dry run: {} run document(s), {} frames, all validated\n", + runs.len(), + total_steps + ); + for run in runs { + let _ = write!(human, "\n{} — {}\n", run.id, run.title); + for (index, step) in run.steps.iter().enumerate() { + let _ = write!( + human, + " [{:>3}] {:<4} {}\n {}\n", + index + 1, + step.command.code(), + step.description, + step.command.frame(), + ); + } + for manual in &run.manual_after { + let _ = writeln!( + human, + " then by hand — {}: {}", + manual.title, manual.instructions + ); + } + } + human +} + +/// Session construction over an arbitrary transport, so the replay loop is +/// exercised or simulated without hardware. +pub fn star_over(transport: Arc) -> Result { + Ok(Star::new(transport)?) +} + +/// Opens the first Hamilton STAR on USB and runs the documented setup +/// choreography. +#[cfg(feature = "hardware")] +pub fn open_usb_star(autoload_park_track: Option) -> Result { + let star = Star::open_usb().context( + "no Hamilton STAR answered on USB; use --dry-run to review the package without hardware", + )?; + star.initialize(hamilton_star::InitializeOptions { + autoload_park_track, + ..hamilton_star::InitializeOptions::default() + }) + .context("the setup choreography failed; the machine is not in a known state")?; + Ok(star) +} + +#[cfg(test)] +mod tests { + use super::*; + use hamilton_star::MockTransport; + + fn loaded(frames: &[(&str, &str)]) -> LoadedRun { + LoadedRun { + id: "test_run".into(), + title: "test".into(), + steps: frames + .iter() + .map(|(frame, description)| LoadedStep { + command: RawCommand::parse(frame).expect("test frames are well-formed"), + description: description.to_string(), + }) + .collect(), + manual_after: Vec::new(), + } + } + + #[test] + fn a_scripted_run_replays_every_frame_in_order() { + let transport = Arc::new(MockTransport::new()); + transport.set_responder(|command| { + let id = command.get(6..10).unwrap_or("0000").to_string(); + vec![format!("{}id{id}er00/00", &command[..4])] + }); + let star = star_over(transport.clone() as Arc).expect("mock opens"); + let runs = vec![loaded(&[ + ("C0TTtt00tf1tl0519tv03600tg2tu0", "define the small tip"), + ("C0ZA", "retract"), + ])]; + let outcome = execute_runs(&star, &runs, &mut |_| true, &mut |_| {}) + .expect("the scripted run completes"); + assert_eq!(outcome, RunOutcome::Completed { steps: 2 }); + let written = transport.written(); + assert_eq!(written.len(), 2, "both frames reached the wire in order"); + assert!( + written[0].starts_with("C0TTid") && written[0].ends_with("tt00tf1tl0519tv03600tg2tu0"), + "the tip definition went first with the session's id spliced in: {}", + written[0] + ); + } + + #[test] + fn a_firmware_error_retracts_and_reports_the_failed_step() { + let transport = Arc::new(MockTransport::new()); + transport.set_responder(|command| { + let id = command.get(6..10).unwrap_or("0000").to_string(); + if &command[2..4] == "TP" { + // The firmware refuses the pickup: a tip is already fitted. + vec![format!("C0TPid{id}er07/00")] + } else { + vec![format!("{}id{id}er00/00", &command[..4])] + } + }); + let star = star_over(transport.clone() as Arc).expect("mock opens"); + let runs = vec![loaded(&[ + ("C0ZA", "retract"), + ( + "C0TPxp01179 01179 00000&yp2418 2328 0000&tm1 1 0&tt01tp2244tz2164th2450td0", + "pick up tips", + ), + ("C0ZA", "never reached"), + ])]; + let outcome = execute_runs(&star, &runs, &mut |_| true, &mut |_| {}) + .expect("an abort is an outcome, not a runner failure"); + let RunOutcome::Aborted { + run_id, + step_index, + error, + } = outcome + else { + panic!("the firmware error aborts the run"); + }; + assert_eq!(run_id, "test_run"); + assert_eq!(step_index, 1, "the pickup was the second step"); + assert!( + error.contains("already fitted"), + "the typed firmware meaning survives into the report: {error}" + ); + let written = transport.written(); + assert!( + written + .last() + .expect("frames were written") + .starts_with("C0ZAid"), + "the runner's last act is the Z-safety retract" + ); + } + + #[test] + fn an_operator_decline_stops_between_runs() { + let transport = Arc::new(MockTransport::new()); + transport.set_responder(|command| { + let id = command.get(6..10).unwrap_or("0000").to_string(); + vec![format!("{}id{id}er00/00", &command[..4])] + }); + let star = star_over(transport.clone() as Arc).expect("mock opens"); + let mut first = loaded(&[("C0ZA", "retract")]); + first.manual_after.push(ManualStep { + title: "thermocycle".into(), + instructions: "off-deck".into(), + }); + let second = loaded(&[("C0ZA", "never reached")]); + let error = execute_runs(&star, &[first, second], &mut |_| false, &mut |_| {}) + .expect_err("declining the manual step stops the program"); + assert!( + error.to_string().contains("stopped by the operator"), + "the stop names its cause: {error}" + ); + assert_eq!( + transport.written().len(), + 1, + "nothing after the declined manual step reached the wire" + ); + } +} diff --git a/crates/lab-runtime/src/stations/mod.rs b/crates/lab-runtime/src/stations/mod.rs new file mode 100644 index 0000000..bfa58d3 --- /dev/null +++ b/crates/lab-runtime/src/stations/mod.rs @@ -0,0 +1,242 @@ +//! Station sessions: the executor's view of connected instruments. +//! +//! The walk talks to stations through two narrow session traits, one per +//! program shape: frame replay on a liquid handler, and thermal programs on +//! a cycler. Live sessions wrap the vendor drivers; simulated sessions +//! (`sim`) advance a virtual clock instead of hardware. Which one a walk +//! gets is the [`Connector`]'s decision, made once per station name. + +pub mod sim; + +use std::collections::BTreeMap; + +#[cfg(feature = "hardware")] +use anyhow::Context; +use anyhow::{Result, bail}; +use hamilton_star::RawCommand; +use lab_instruments::{RunHandle, ThermalProfile}; + +use crate::events::EventSink; +#[cfg(feature = "hardware")] +use crate::events::RunEvent; +use crate::workcell::Bench; + +/// A session that replays reviewed STAR frames. +pub trait StarSession { + /// Executes one frame; the error is the firmware's meaning, as text. + fn execute(&mut self, command: &RawCommand) -> Result<(), String>; + /// Best-effort Z-safety retract after a failure; never fails louder + /// than the failure it follows. + fn retract(&mut self); +} + +/// A session that runs device-neutral thermal programs. +pub trait CyclerSession { + fn open_lid(&mut self) -> Result<()>; + fn close_lid(&mut self) -> Result<()>; + /// Drops any hold; the plate is out and nothing needs temperature. + fn stop(&mut self) -> Result<()>; + fn run_profile(&mut self, profile: &ThermalProfile) -> Result; + fn await_completion(&mut self, handle: RunHandle) -> Result<()>; + fn hold_block(&mut self, celsius: f64) -> Result<()>; + /// Warnings the device raised during the run, drained. + fn take_warnings(&mut self) -> Vec; +} + +/// One open station, whichever shape it has. +pub enum StationSession { + Star(Box), + Cycler(Box), +} + +/// Opens a session for a station the walk touches for the first time. The +/// live connector reaches hardware; the simulated connector builds models. +pub trait Connector { + fn connect( + &mut self, + station: &str, + kind: &str, + bench: &Bench, + events: &mut dyn EventSink, + ) -> Result; +} + +/// The open sessions a walk accumulates: each station connects on first +/// use, keyed by name, and stays open for the wave. +pub struct Sessions<'connector> { + open: BTreeMap, + connector: &'connector mut dyn Connector, +} + +impl<'connector> Sessions<'connector> { + pub fn new(connector: &'connector mut dyn Connector) -> Self { + Self { + open: BTreeMap::new(), + connector, + } + } + + pub fn ensure( + &mut self, + station: &str, + kind: &str, + bench: &Bench, + events: &mut dyn EventSink, + ) -> Result<&mut StationSession> { + if !self.open.contains_key(station) { + let session = self.connector.connect(station, kind, bench, events)?; + self.open.insert(station.to_string(), session); + } + Ok(self + .open + .get_mut(station) + .expect("the session was just ensured")) + } + + pub fn ensure_star( + &mut self, + station: &str, + kind: &str, + bench: &Bench, + events: &mut dyn EventSink, + ) -> Result<&mut dyn StarSession> { + match self.ensure(station, kind, bench, events)? { + StationSession::Star(session) => Ok(session.as_mut()), + StationSession::Cycler(_) => { + bail!("station '{station}' is a cycler, not a liquid handler") + } + } + } + + pub fn ensure_cycler( + &mut self, + station: &str, + kind: &str, + bench: &Bench, + events: &mut dyn EventSink, + ) -> Result<&mut dyn CyclerSession> { + match self.ensure(station, kind, bench, events)? { + StationSession::Cycler(session) => Ok(session.as_mut()), + StationSession::Star(_) => { + bail!("station '{station}' is a liquid handler, not a cycler") + } + } + } +} + +/// A live STAR session over any transport. +pub struct LiveStar { + star: hamilton_star::Star, +} + +impl LiveStar { + pub fn new(star: hamilton_star::Star) -> Self { + Self { star } + } +} + +impl StarSession for LiveStar { + fn execute(&mut self, command: &RawCommand) -> Result<(), String> { + self.star + .execute_raw(command) + .map(|_| ()) + .map_err(|error| error.to_string()) + } + + fn retract(&mut self) { + let retract = + RawCommand::parse("C0ZA").expect("the retract frame is a constant well-formed frame"); + let _ = self.star.execute_raw(&retract); + } +} + +impl CyclerSession for lab_instruments::OdtcStation { + fn open_lid(&mut self) -> Result<()> { + lab_instruments::Thermocycler::open_lid(self).map_err(anyhow::Error::from) + } + + fn close_lid(&mut self) -> Result<()> { + lab_instruments::Thermocycler::close_lid(self).map_err(anyhow::Error::from) + } + + fn stop(&mut self) -> Result<()> { + lab_instruments::Thermocycler::stop(self).map_err(anyhow::Error::from) + } + + fn run_profile(&mut self, profile: &ThermalProfile) -> Result { + lab_instruments::Thermocycler::run_profile(self, profile).map_err(anyhow::Error::from) + } + + fn await_completion(&mut self, handle: RunHandle) -> Result<()> { + lab_instruments::Thermocycler::await_completion(self, handle).map_err(anyhow::Error::from) + } + + fn hold_block(&mut self, celsius: f64) -> Result<()> { + lab_instruments::Thermocycler::hold_block(self, celsius, None).map_err(anyhow::Error::from) + } + + fn take_warnings(&mut self) -> Vec { + lab_instruments::OdtcStation::take_warnings(self) + } +} + +/// The connector live runs use: USB for the STAR, the bench's address for +/// the ODTC. Available only with the `hardware` feature so simulation +/// builds never link libusb. +#[cfg(feature = "hardware")] +pub struct HardwareConnector; + +#[cfg(feature = "hardware")] +impl Connector for HardwareConnector { + fn connect( + &mut self, + station: &str, + kind: &str, + bench: &Bench, + events: &mut dyn EventSink, + ) -> Result { + match kind { + "hamilton.star" => { + events.emit(RunEvent::Connecting { + station: station.to_string(), + detail: "the first Hamilton STAR on USB".to_string(), + }); + let star = hamilton_star::Star::open_usb().context( + "no Hamilton STAR answered on USB; use --dry-run to review without hardware", + )?; + star.initialize(hamilton_star::InitializeOptions::default()) + .context( + "the setup choreography failed; the machine is not in a known state", + )?; + events.emit(RunEvent::Connected { + station: station.to_string(), + }); + Ok(StationSession::Star(Box::new(LiveStar::new(star)))) + } + "inheco.odtc" => { + let address = bench.addresses.get(station).with_context(|| { + format!( + "station '{station}' has no address on this bench; pass --station {station}= (the ODTC answers on port 8080)" + ) + })?; + let socket: std::net::SocketAddr = address.parse().with_context(|| { + format!("'{address}' is not an address for station '{station}'") + })?; + events.emit(RunEvent::Connecting { + station: station.to_string(), + detail: socket.to_string(), + }); + let session = lab_instruments::OdtcStation::connect(socket).with_context(|| { + format!("the {station} connection handshake failed at {socket}") + })?; + events.emit(RunEvent::Connected { + station: station.to_string(), + }); + Ok(StationSession::Cycler(Box::new(session))) + } + other => bail!( + "station '{station}' has kind '{other}', which this runner has no executor for" + ), + } + } +} diff --git a/crates/lab-runtime/src/stations/sim.rs b/crates/lab-runtime/src/stations/sim.rs new file mode 100644 index 0000000..d535629 --- /dev/null +++ b/crates/lab-runtime/src/stations/sim.rs @@ -0,0 +1,164 @@ +//! Simulated stations: the same session traits the live walk drives, over +//! a virtual clock instead of hardware. +//! +//! A simulated station is honest about what it models: time, block +//! temperature, and door state. It never invents readings the real device +//! would measure. + +use std::cell::RefCell; +use std::rc::Rc; + +use anyhow::{Result, bail}; +use hamilton_star::RawCommand; +use lab_instruments::{RunHandle, ThermalLimits, ThermalProfile, odtc_thermal_limits}; + +use crate::clock::VirtualClock; +use crate::durations::DurationModel; +use crate::events::EventSink; +use crate::stations::{Connector, CyclerSession, StarSession, StationSession}; +use crate::workcell::Bench; + +/// The one clock a simulation shares: every station and the modeled +/// operator advance it as their work completes. +pub type SharedClock = Rc>; + +/// A simulated STAR: every frame succeeds and costs its modeled time. +pub struct SimStar { + clock: SharedClock, + durations: Rc, +} + +impl SimStar { + pub fn new(clock: SharedClock, durations: Rc) -> Self { + Self { clock, durations } + } +} + +impl StarSession for SimStar { + fn execute(&mut self, command: &RawCommand) -> Result<(), String> { + let frame = command.frame(); + let module = frame.get(..2).unwrap_or(""); + let seconds = self.durations.star_frame_seconds(module, command.code()); + self.clock.borrow_mut().advance(seconds); + Ok(()) + } + + fn retract(&mut self) { + let seconds = self.durations.star_frame_seconds("C0", "ZA"); + self.clock.borrow_mut().advance(seconds); + } +} + +/// A simulated thermocycler: profiles complete in exactly their computed +/// time, and the block temperature carries between programs. +pub struct SimThermocycler { + clock: SharedClock, + durations: Rc, + limits: ThermalLimits, + block_celsius: f64, + pending: Option<(RunHandle, f64, f64)>, + next_handle: u64, +} + +impl SimThermocycler { + pub fn new(clock: SharedClock, durations: Rc) -> Self { + let ambient = durations.ambient_celsius; + Self { + clock, + durations, + limits: odtc_thermal_limits(), + block_celsius: ambient, + pending: None, + next_handle: 1, + } + } +} + +impl CyclerSession for SimThermocycler { + fn open_lid(&mut self) -> Result<()> { + self.clock.borrow_mut().advance(self.durations.door_seconds); + Ok(()) + } + + fn close_lid(&mut self) -> Result<()> { + self.clock.borrow_mut().advance(self.durations.door_seconds); + Ok(()) + } + + fn stop(&mut self) -> Result<()> { + self.pending = None; + Ok(()) + } + + fn run_profile(&mut self, profile: &ThermalProfile) -> Result { + let (seconds, final_celsius) = + self.durations + .thermal_profile_seconds(profile, &self.limits, self.block_celsius); + let handle = RunHandle::new(self.next_handle); + self.next_handle += 1; + self.pending = Some((handle, seconds, final_celsius)); + Ok(handle) + } + + fn await_completion(&mut self, handle: RunHandle) -> Result<()> { + let Some((pending, seconds, final_celsius)) = self.pending.take() else { + bail!("no thermal program is running"); + }; + if pending != handle { + bail!("the awaited handle is not the running program's"); + } + self.clock.borrow_mut().advance(seconds); + self.block_celsius = final_celsius; + Ok(()) + } + + fn hold_block(&mut self, celsius: f64) -> Result<()> { + let seconds = + self.durations + .thermal_ramp_seconds(&self.limits, self.block_celsius, celsius); + self.clock.borrow_mut().advance(seconds); + self.block_celsius = celsius; + Ok(()) + } + + fn take_warnings(&mut self) -> Vec { + Vec::new() + } +} + +/// The connector a simulation uses: every station kind the live runner +/// knows gets a simulated session over the shared clock. +pub struct SimConnector { + clock: SharedClock, + durations: Rc, +} + +impl SimConnector { + pub fn new(clock: SharedClock, durations: Rc) -> Self { + Self { clock, durations } + } +} + +impl Connector for SimConnector { + fn connect( + &mut self, + station: &str, + kind: &str, + _bench: &Bench, + _events: &mut dyn EventSink, + ) -> Result { + match kind { + "hamilton.star" => Ok(StationSession::Star(Box::new(SimStar::new( + self.clock.clone(), + self.durations.clone(), + )))), + "inheco.odtc" => Ok(StationSession::Cycler(Box::new(SimThermocycler::new( + self.clock.clone(), + self.durations.clone(), + )))), + other => bail!( + "station '{station}' has kind '{other}', which this simulator has no model for" + ), + } + } +} diff --git a/crates/lab-runtime/src/testing.rs b/crates/lab-runtime/src/testing.rs new file mode 100644 index 0000000..19ec773 --- /dev/null +++ b/crates/lab-runtime/src/testing.rs @@ -0,0 +1,73 @@ +//! Test fixtures: a synthetic workcell wave with the same shape the +//! workcell backend emits — one STAR run, a thermal program bracketed by +//! two handoffs, and a trailing manual step. + +use std::path::Path; + +pub(crate) fn write_synthetic_wave(directory: &Path) { + let plan = serde_json::json!({ + "format": "lab.workcell-run.v0", + "stations": [ + { "name": "star-1", "kind": "hamilton.star", "program_dir": "stations/star-1" }, + { "name": "odtc-1", "kind": "inheco.odtc", "program_dir": "stations/odtc-1" } + ], + "nodes": [ + { "id": "assembly_run", "after": [], "action": "station-program", + "station": "star-1", "document": "stations/star-1/assembly_run.star.json" }, + { "id": "assembly_thermocycle.to-odtc-1", "after": ["assembly_run"], + "action": "handoff", "from": "star-1", "to": "odtc-1", + "labware": "reaction_plate", + "instructions": "Seal the reaction_plate and move it from star-1 to odtc-1; close the door." }, + { "id": "assembly_thermocycle", "after": ["assembly_thermocycle.to-odtc-1"], + "action": "station-program", "station": "odtc-1", + "document": "stations/odtc-1/assembly_thermocycle.odtc.json" }, + { "id": "assembly_thermocycle.return", "after": ["assembly_thermocycle"], + "action": "handoff", "from": "odtc-1", "to": "star-1", + "labware": "reaction_plate", + "instructions": "Retrieve the reaction_plate from odtc-1 and return it to the star-1 deck position it came from." }, + { "id": "assembly_run.manual-1", "after": ["assembly_thermocycle.return"], + "action": "manual", "title": "spread plates", + "instructions": "spread the transformation on selective agar" } + ] + }); + let star_run = serde_json::json!({ + "format": "lab.star-run.v0", + "run": "assembly_run", + "title": "Golden Gate assembly", + "machine": "STARlet", + "channels": 8, + "steps": [ + { "frame": "C0TTtt00tf1tl0519tv03600tg2tu0", "module": "C0", "code": "TT", + "description": "define the small tip" }, + { "frame": "C0ZA", "module": "C0", "code": "ZA", + "description": "retract all channels to Z-safety" } + ] + }); + let thermocycle = serde_json::json!({ + "format": "lab.thermocycle-run.v0", + "id": "assembly_thermocycle", + "title": "Golden Gate cycling", + "plate": "reaction_plate", + "profile": { "stages": [ + { "steps": [ { "celsius": 37.0, "hold_seconds": 90.0 } ], "repeats": 1 } + ] }, + "fill_volume_ul": 20.0 + }); + std::fs::create_dir_all(directory.join("stations/star-1")).unwrap(); + std::fs::create_dir_all(directory.join("stations/odtc-1")).unwrap(); + std::fs::write( + directory.join("plan.workcell.json"), + serde_json::to_string_pretty(&plan).unwrap(), + ) + .unwrap(); + std::fs::write( + directory.join("stations/star-1/assembly_run.star.json"), + serde_json::to_string_pretty(&star_run).unwrap(), + ) + .unwrap(); + std::fs::write( + directory.join("stations/odtc-1/assembly_thermocycle.odtc.json"), + serde_json::to_string_pretty(&thermocycle).unwrap(), + ) + .unwrap(); +} diff --git a/crates/lab-runtime/src/trace.rs b/crates/lab-runtime/src/trace.rs new file mode 100644 index 0000000..a1f7cb3 --- /dev/null +++ b/crates/lab-runtime/src/trace.rs @@ -0,0 +1,139 @@ +//! `lab.sim-trace.v0`: the record a simulation leaves behind. +//! +//! The trace is the contract for all visualization. Every event carries the +//! virtual time it happened at; a viewer plays events and computes nothing. +//! Like the run formats, a change to what an event means is a new format +//! version, not an edit. + +use std::cell::RefCell; +use std::collections::BTreeMap; +use std::rc::Rc; + +use serde::{Deserialize, Serialize}; + +use crate::clock::VirtualClock; +use crate::events::{EventSink, RunEvent}; + +/// The format string every `lab.sim-trace.v0` document declares. +pub const SIM_TRACE_FORMAT: &str = "lab.sim-trace.v0"; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct SimTraceDocument { + /// Always [`SIM_TRACE_FORMAT`]; readers reject any other value. + pub format: String, + /// What was simulated: the plan or manifest path, relative to the + /// package directory. + pub plan: String, + /// The duration model's name; timings are estimates under that model. + pub durations: String, + pub events: Vec, + pub summary: SimSummary, +} + +/// One event at a virtual time, in seconds from the simulation's start. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TimedEvent { + pub t: f64, + #[serde(flatten)] + pub event: RunEvent, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct SimSummary { + pub total_seconds: f64, + /// Seconds an operator must be present. + pub attended_seconds: f64, + /// Seconds the run proceeds without anyone watching. + pub walkaway_seconds: f64, + pub nodes: usize, + pub stations: BTreeMap, + pub attention_windows: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct StationSummary { + pub busy_seconds: f64, +} + +/// One interval an operator is needed for, and why. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct AttentionWindow { + pub node: String, + pub from_seconds: f64, + pub to_seconds: f64, +} + +/// The sink a simulation emits through: stamps every event with the shared +/// clock's current time. +pub struct TraceSink { + clock: Rc>, + pub events: Vec, +} + +impl TraceSink { + pub fn new(clock: Rc>) -> Self { + Self { + clock, + events: Vec::new(), + } + } +} + +impl EventSink for TraceSink { + fn emit(&mut self, event: RunEvent) { + self.events.push(TimedEvent { + t: self.clock.borrow().elapsed_seconds(), + event, + }); + } +} + +/// Derives the summary a trace carries: totals, attended intervals, and +/// per-station busy time, all from the recorded events. +pub fn summarize(events: &[TimedEvent], total_seconds: f64) -> SimSummary { + let mut attention_windows = Vec::new(); + let mut open_attention: Option<(String, f64)> = None; + let mut stations: BTreeMap = BTreeMap::new(); + let mut open_program: Option<(String, f64)> = None; + let mut nodes = 0usize; + + for timed in events { + match &timed.event { + RunEvent::AttentionRequired { node, .. } => { + open_attention = Some((node.clone(), timed.t)); + } + RunEvent::AttentionReleased { .. } => { + if let Some((node, from_seconds)) = open_attention.take() { + attention_windows.push(AttentionWindow { + node, + from_seconds, + to_seconds: timed.t, + }); + } + } + RunEvent::ProgramStarted { station, .. } => { + open_program = Some((station.clone(), timed.t)); + } + RunEvent::NodeCompleted { .. } => { + nodes += 1; + if let Some((station, from)) = open_program.take() { + stations.entry(station).or_default().busy_seconds += timed.t - from; + } + } + _ => {} + } + } + + let attended_seconds: f64 = attention_windows + .iter() + .map(|window| window.to_seconds - window.from_seconds) + .sum(); + SimSummary { + total_seconds, + attended_seconds, + walkaway_seconds: (total_seconds - attended_seconds).max(0.0), + nodes, + stations, + attention_windows, + } +} diff --git a/crates/lab-runtime/src/workcell.rs b/crates/lab-runtime/src/workcell.rs new file mode 100644 index 0000000..e1c3ea5 --- /dev/null +++ b/crates/lab-runtime/src/workcell.rs @@ -0,0 +1,781 @@ +//! The workcell walk: one node-by-node interpretation of a coordination +//! plan, shared by live execution and simulation. +//! +//! `plan.workcell.json` names every node; station programs run on their +//! instruments, and every handoff or manual step stops for the operator's +//! confirmation. During a live run a durable ledger records each node as it +//! completes, so an interrupted wave — a crash, a power cut, an overnight +//! incubation — resumes from the first incomplete node with `--resume` +//! instead of repeating motion that already happened. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +use anyhow::{Context, Result, bail}; +use hamilton_star::RawCommand; +use lab_runfmt::{StarRunDocument, ThermocycleRunDocument, WORKCELL_PLAN_FILE, WorkcellAction}; + +use crate::clock::Clock; +use crate::events::{EventSink, ProgramExtent, RunEvent}; +use crate::ledger::{LEDGER_FILE, LedgerEvent, append_ledger, completed_nodes}; +use crate::operator::{ConfirmKind, Operator}; +use crate::stations::{Connector, Sessions}; + +/// A station program, loaded and validated up front so nothing is +/// discovered mid-walk. +pub enum LoadedProgram { + Star { + station: String, + document: StarRunDocument, + steps: Vec<(RawCommand, String)>, + }, + Thermocycle { + station: String, + document: ThermocycleRunDocument, + }, +} + +/// One executable unit of the walk, in plan order. +pub struct LoadedNode { + pub id: String, + /// Node ids that must complete first. The plan emits a linear chain + /// today; the simulator asserts this ordering rather than trusting it. + pub after: Vec, + pub action: LoadedAction, +} + +pub enum LoadedAction { + Program(LoadedProgram), + Handoff { + from: String, + to: String, + labware: String, + instructions: String, + }, + Manual { + title: String, + instructions: String, + }, +} + +impl LoadedAction { + /// The operator-facing text for a handoff, naming the labware and both + /// endpoints. + pub fn handoff_prompt(from: &str, to: &str, labware: &str, instructions: &str) -> String { + format!("{instructions} ({labware}: {from} -> {to})") + } +} + +pub struct LoadedWorkcell { + pub nodes: Vec, + /// The thermocycler station's name, when the plan declares one. + pub thermocycler_station: Option, +} + +/// True when the directory holds a workcell coordination plan. +pub fn is_workcell_directory(directory: &Path) -> bool { + directory.join(WORKCELL_PLAN_FILE).is_file() +} + +/// Loads a wave directory: the coordination plan names every node, and +/// every referenced station document must parse and validate before +/// anything is reported ready. Thermal programs are checked against the +/// cycler's envelope here, so an unrunnable profile fails before any +/// motion — the same eagerness STAR frames get. +pub fn load_workcell_directory(directory: &Path) -> Result { + let plan = lab_runfmt::load_workcell_plan(directory)?; + + let station_kind = |name: &str| -> Result<&str> { + plan.stations + .iter() + .find(|station| station.name == name) + .map(|station| station.kind.as_str()) + .with_context(|| format!("the plan references station '{name}' it never declares")) + }; + + let mut nodes = Vec::new(); + for node in &plan.nodes { + let action = match &node.action { + WorkcellAction::StationProgram { station, document } => { + let path = directory.join(document); + match station_kind(station)? { + "hamilton.star" => { + let document = lab_runfmt::load_star_run(&path)?; + let steps = document + .steps + .iter() + .map(|step| { + RawCommand::parse(&step.frame) + .map(|command| (command, step.description.clone())) + .with_context(|| { + format!("{} carries an unreplayable frame", path.display()) + }) + }) + .collect::>>()?; + LoadedAction::Program(LoadedProgram::Star { + station: station.clone(), + document, + steps, + }) + } + "inheco.odtc" => { + let document = lab_runfmt::load_thermocycle(&path)?; + document + .profile + .validate(&lab_instruments::odtc_thermal_limits()) + .with_context(|| { + format!("'{}' is outside the {station} envelope", document.id) + })?; + LoadedAction::Program(LoadedProgram::Thermocycle { + station: station.clone(), + document, + }) + } + other => bail!( + "station '{station}' has kind '{other}', which this runner has no executor for" + ), + } + } + WorkcellAction::Handoff { + from, + to, + labware, + instructions, + } => LoadedAction::Handoff { + from: from.clone(), + to: to.clone(), + labware: labware.clone(), + instructions: instructions.clone(), + }, + WorkcellAction::Manual { + title, + instructions, + } => LoadedAction::Manual { + title: title.clone(), + instructions: instructions.clone(), + }, + }; + nodes.push(LoadedNode { + id: node.id.clone(), + after: node.after.clone(), + action, + }); + } + let thermocycler_station = plan + .stations + .iter() + .find(|station| station.kind == "inheco.odtc") + .map(|station| station.name.clone()); + Ok(LoadedWorkcell { + nodes, + thermocycler_station, + }) +} + +/// Renders the dry-run walk: every node in order, with program contents +/// summarized the way the live run narrates them. +pub fn render_dry_run(loaded: &LoadedWorkcell) -> String { + use std::fmt::Write; + let mut text = String::new(); + let _ = writeln!( + text, + "dry run: {} coordination node(s), all documents validated", + loaded.nodes.len() + ); + for (index, node) in loaded.nodes.iter().enumerate() { + match &node.action { + LoadedAction::Program(LoadedProgram::Star { + station, + document, + steps, + }) => { + let _ = writeln!( + text, + "\n[{}] {} on {station} — {} ({} frames)", + index + 1, + node.id, + document.title, + steps.len() + ); + } + LoadedAction::Program(LoadedProgram::Thermocycle { station, document }) => { + let _ = writeln!( + text, + "\n[{}] {} on {station} — {} ({} plateaus{})", + index + 1, + node.id, + document.title, + document.profile.total_steps(), + match document.final_hold_celsius { + Some(celsius) => format!(", then hold {celsius} °C"), + None => String::new(), + } + ); + } + LoadedAction::Handoff { + from, + to, + labware, + instructions, + } => { + let _ = writeln!( + text, + "\n[{}] {} — by hand: {}", + index + 1, + node.id, + LoadedAction::handoff_prompt(from, to, labware, instructions) + ); + } + LoadedAction::Manual { + title, + instructions, + } => { + let _ = writeln!( + text, + "\n[{}] {} — by hand: {title}: {instructions}", + index + 1, + node.id + ); + } + } + } + text +} + +/// Bench context the walk carries: which station is the cycler, and where +/// stations answer on this bench. Addresses are runtime input — compiled +/// artifacts never carry them. +pub struct Bench { + pub thermocycler_station: Option, + pub addresses: BTreeMap, +} + +/// Parses repeated `--station NAME=ADDRESS` flags. +pub fn parse_station_addresses(entries: &[String]) -> Result> { + let mut addresses = BTreeMap::new(); + for entry in entries { + let Some((name, address)) = entry.split_once('=') else { + bail!("--station takes NAME=ADDRESS, e.g. --station odtc-1=169.254.10.40:8080"); + }; + addresses.insert(name.to_string(), address.to_string()); + } + Ok(addresses) +} + +/// How one live walk was configured. +pub struct RunConfig { + /// Skip the pre-run gate. Handoff and manual confirmations always ask: + /// they attest that a physical step happened, and no flag can attest + /// that for the operator. + pub assume_yes: bool, + pub resume: bool, +} + +/// How a walk ended. +#[derive(Debug, PartialEq, Eq)] +pub enum WorkcellOutcome { + Completed { + executed: usize, + skipped: usize, + }, + /// The operator declined the pre-run gate; nothing moved. + Cancelled, + /// The operator declined a handoff or manual step mid-walk. + Declined { + node: String, + }, + /// A station failed; the ledger holds every completed node. + Failed { + node: String, + error: String, + }, +} + +/// How one node's execution ended. +pub(crate) enum NodeRun { + Done, + Declined, +} + +/// The live workcell walk: validate everything, then walk the plan in +/// order, recording each node in the ledger as it completes. +#[allow(clippy::too_many_arguments)] +pub fn run_workcell( + directory: &Path, + loaded: &LoadedWorkcell, + bench: &Bench, + config: &RunConfig, + connector: &mut dyn Connector, + operator: &mut dyn Operator, + events: &mut dyn EventSink, + clock: &dyn Clock, +) -> Result { + let completed = if config.resume { + completed_nodes(directory)? + } else { + let ledger = directory.join(LEDGER_FILE); + if ledger.is_file() { + bail!( + "{} already exists; a wave that stopped mid-run continues with --resume, and a fresh run of the same wave means physical state this runner cannot verify — remove the ledger only if the bench was truly reset", + ledger.display() + ); + } + BTreeSet::new() + }; + + let pending = loaded + .nodes + .iter() + .filter(|node| !completed.contains(&node.id)) + .count(); + events.emit(RunEvent::Planned { + pending, + completed: completed.len(), + }); + if !config.assume_yes + && !operator.confirm(ConfirmKind::PreRun, "proceed? Stations will move. [y/N] ")? + { + return Ok(WorkcellOutcome::Cancelled); + } + + let mut sessions = Sessions::new(connector); + let mut executed = 0usize; + for node in &loaded.nodes { + if completed.contains(&node.id) { + events.emit(RunEvent::NodeSkipped { + id: node.id.clone(), + }); + continue; + } + append_ledger(directory, &node.id, LedgerEvent::Started, clock)?; + events.emit(RunEvent::NodeStarted { + id: node.id.clone(), + }); + match execute_node(node, &mut sessions, bench, operator, events) { + Ok(NodeRun::Done) => { + append_ledger(directory, &node.id, LedgerEvent::Completed, clock)?; + events.emit(RunEvent::NodeCompleted { + id: node.id.clone(), + }); + executed += 1; + } + Ok(NodeRun::Declined) => { + append_ledger(directory, &node.id, LedgerEvent::Failed, clock)?; + return Ok(WorkcellOutcome::Declined { + node: node.id.clone(), + }); + } + Err(error) => { + append_ledger(directory, &node.id, LedgerEvent::Failed, clock)?; + return Ok(WorkcellOutcome::Failed { + node: node.id.clone(), + error: format!("{error:#}"), + }); + } + } + } + Ok(WorkcellOutcome::Completed { + executed, + skipped: completed.len(), + }) +} + +/// True when a handoff endpoint is the cycler, whose motorized door the +/// runner must open before the operator can reach the block. +fn involves_cycler(bench: &Bench, station: &str) -> bool { + bench + .thermocycler_station + .as_deref() + .is_some_and(|cycler| cycler == station) +} + +/// Executes one node against its station, confirming with the operator +/// wherever the plan needs hands. Shared verbatim by live runs and the +/// simulator: only the injected ports differ. +pub(crate) fn execute_node( + node: &LoadedNode, + sessions: &mut Sessions, + bench: &Bench, + operator: &mut dyn Operator, + events: &mut dyn EventSink, +) -> Result { + match &node.action { + LoadedAction::Handoff { + from, + to, + labware, + instructions, + } => { + let to_cycler = involves_cycler(bench, to); + let from_cycler = involves_cycler(bench, from); + let cycler_endpoint = if to_cycler { + Some(to.as_str()) + } else if from_cycler { + Some(from.as_str()) + } else { + None + }; + if let Some(station) = cycler_endpoint { + let cycler = sessions.ensure_cycler(station, "inheco.odtc", bench, events)?; + cycler + .open_lid() + .with_context(|| format!("could not open the {station} door"))?; + events.emit(RunEvent::DoorOpened { + station: station.to_string(), + }); + } + let prompt = LoadedAction::handoff_prompt(from, to, labware, instructions); + events.emit(RunEvent::AttentionRequired { + node: node.id.clone(), + prompt, + }); + let confirmed = operator.confirm( + ConfirmKind::Handoff, + "done, and the bench matches the plan? Continue [y/N] ", + )?; + events.emit(RunEvent::AttentionReleased { + node: node.id.clone(), + }); + if !confirmed { + return Ok(NodeRun::Declined); + } + events.emit(RunEvent::LabwareMoved { + labware: labware.clone(), + from: from.clone(), + to: to.clone(), + }); + if let Some(station) = cycler_endpoint { + let cycler = sessions.ensure_cycler(station, "inheco.odtc", bench, events)?; + if from_cycler { + // The plate is out; nothing holds temperature for it now. + cycler + .stop() + .with_context(|| format!("could not stop {station} after retrieval"))?; + } + cycler + .close_lid() + .with_context(|| format!("could not close the {station} door"))?; + events.emit(RunEvent::DoorClosed { + station: station.to_string(), + }); + } + Ok(NodeRun::Done) + } + LoadedAction::Manual { + title, + instructions, + } => { + events.emit(RunEvent::AttentionRequired { + node: node.id.clone(), + prompt: format!("{title}: {instructions}"), + }); + let confirmed = operator.confirm( + ConfirmKind::Manual, + "done, and the bench matches the plan? Continue [y/N] ", + )?; + events.emit(RunEvent::AttentionReleased { + node: node.id.clone(), + }); + if confirmed { + Ok(NodeRun::Done) + } else { + Ok(NodeRun::Declined) + } + } + LoadedAction::Program(LoadedProgram::Star { + station, + document, + steps, + }) => { + events.emit(RunEvent::ProgramStarted { + station: station.clone(), + title: document.title.clone(), + extent: ProgramExtent::Frames { + frames: steps.len(), + }, + }); + let star = sessions.ensure_star(station, "hamilton.star", bench, events)?; + for (index, (command, description)) in steps.iter().enumerate() { + let position = crate::events::frame_position(command.frame()); + events.emit(RunEvent::Frame { + station: station.clone(), + index: index + 1, + description: description.clone(), + x_mm: position.map(|(x, _)| x), + y_mm: position.map(|(_, y)| y), + }); + if let Err(error) = star.execute(command) { + star.retract(); + bail!( + "firmware error at frame {}: {error}; channels were retracted to Z-safety", + index + 1 + ); + } + } + Ok(NodeRun::Done) + } + LoadedAction::Program(LoadedProgram::Thermocycle { station, document }) => { + events.emit(RunEvent::ProgramStarted { + station: station.clone(), + title: document.title.clone(), + extent: ProgramExtent::Plateaus { + plateaus: document.profile.total_steps(), + final_hold_celsius: document.final_hold_celsius, + }, + }); + let cycler = sessions.ensure_cycler(station, "inheco.odtc", bench, events)?; + let handle = cycler + .run_profile(&document.profile) + .with_context(|| format!("could not start '{}' on {station}", document.id))?; + events.emit(RunEvent::ThermalRunning { + station: station.clone(), + }); + cycler + .await_completion(handle) + .with_context(|| format!("'{}' did not complete on {station}", document.id))?; + for warning in cycler.take_warnings() { + events.emit(RunEvent::ThermalWarning { + station: station.clone(), + warning, + }); + } + if let Some(celsius) = document.final_hold_celsius { + cycler + .hold_block(celsius) + .with_context(|| format!("could not hold {celsius} °C on {station}"))?; + events.emit(RunEvent::ThermalHold { + station: station.clone(), + celsius, + }); + } + Ok(NodeRun::Done) + } + } +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::rc::Rc; + + use super::*; + use crate::clock::{VirtualClock, WallClock}; + use crate::durations::DurationModel; + use crate::events::{RecordingSink, RunEvent}; + use crate::operator::AutoOperator; + use crate::stations::sim::SimConnector; + use crate::testing::write_synthetic_wave; + + fn sim_connector() -> SimConnector { + SimConnector::new( + Rc::new(RefCell::new(VirtualClock::new(0))), + Rc::new(DurationModel::default()), + ) + } + + fn bench_for(loaded: &LoadedWorkcell) -> Bench { + Bench { + thermocycler_station: loaded.thermocycler_station.clone(), + addresses: std::collections::BTreeMap::new(), + } + } + + #[test] + fn a_wave_walks_in_order_and_records_every_node_in_the_ledger() { + let directory = tempfile::tempdir().unwrap(); + write_synthetic_wave(directory.path()); + let loaded = load_workcell_directory(directory.path()).unwrap(); + let bench = bench_for(&loaded); + let mut connector = sim_connector(); + let mut operator = AutoOperator { answer: true }; + let mut sink = RecordingSink::default(); + let outcome = run_workcell( + directory.path(), + &loaded, + &bench, + &RunConfig { + assume_yes: true, + resume: false, + }, + &mut connector, + &mut operator, + &mut sink, + &WallClock, + ) + .unwrap(); + assert_eq!( + outcome, + WorkcellOutcome::Completed { + executed: 5, + skipped: 0 + } + ); + let started: Vec<&str> = sink + .events + .iter() + .filter_map(|event| match event { + RunEvent::NodeStarted { id } => Some(id.as_str()), + _ => None, + }) + .collect(); + assert_eq!( + started, + [ + "assembly_run", + "assembly_thermocycle.to-odtc-1", + "assembly_thermocycle", + "assembly_thermocycle.return", + "assembly_run.manual-1", + ], + "the walk follows plan order" + ); + let completed = crate::ledger::completed_nodes(directory.path()).unwrap(); + assert_eq!(completed.len(), 5, "every node is durable in the ledger"); + assert!( + sink.events.iter().any(|event| matches!( + event, + RunEvent::LabwareMoved { labware, .. } if labware == "reaction_plate" + )), + "a confirmed handoff records the labware movement" + ); + let doors: Vec<&RunEvent> = sink + .events + .iter() + .filter(|event| { + matches!( + event, + RunEvent::DoorOpened { .. } | RunEvent::DoorClosed { .. } + ) + }) + .collect(); + assert_eq!( + doors.len(), + 4, + "the cycler door opens and closes around each handoff" + ); + } + + #[test] + fn a_declining_operator_stops_the_walk_at_the_handoff() { + let directory = tempfile::tempdir().unwrap(); + write_synthetic_wave(directory.path()); + let loaded = load_workcell_directory(directory.path()).unwrap(); + let bench = bench_for(&loaded); + let mut connector = sim_connector(); + // Confirms the pre-run gate implicitly (assume_yes), then declines + // the first handoff. + let mut operator = AutoOperator { answer: false }; + let mut sink = RecordingSink::default(); + let outcome = run_workcell( + directory.path(), + &loaded, + &bench, + &RunConfig { + assume_yes: true, + resume: false, + }, + &mut connector, + &mut operator, + &mut sink, + &WallClock, + ) + .unwrap(); + assert_eq!( + outcome, + WorkcellOutcome::Declined { + node: "assembly_thermocycle.to-odtc-1".to_string() + }, + "the walk stops at the first confirmation" + ); + let completed = crate::ledger::completed_nodes(directory.path()).unwrap(); + assert_eq!( + completed.len(), + 1, + "only the STAR run completed before the decline" + ); + } + + #[test] + fn resume_skips_ledgered_nodes_and_a_fresh_run_refuses_a_ledger() { + let directory = tempfile::tempdir().unwrap(); + write_synthetic_wave(directory.path()); + let loaded = load_workcell_directory(directory.path()).unwrap(); + let bench = bench_for(&loaded); + crate::ledger::append_ledger( + directory.path(), + "assembly_run", + crate::ledger::LedgerEvent::Completed, + &WallClock, + ) + .unwrap(); + + let mut connector = sim_connector(); + let mut operator = AutoOperator { answer: true }; + let mut sink = RecordingSink::default(); + let fresh = run_workcell( + directory.path(), + &loaded, + &bench, + &RunConfig { + assume_yes: true, + resume: false, + }, + &mut connector, + &mut operator, + &mut sink, + &WallClock, + ); + assert!( + fresh.is_err(), + "a pre-existing ledger without --resume is physical state the runner cannot verify" + ); + + let resumed = run_workcell( + directory.path(), + &loaded, + &bench, + &RunConfig { + assume_yes: true, + resume: true, + }, + &mut connector, + &mut operator, + &mut sink, + &WallClock, + ) + .unwrap(); + assert_eq!( + resumed, + WorkcellOutcome::Completed { + executed: 4, + skipped: 1 + } + ); + assert!( + sink.events + .iter() + .any(|event| matches!(event, RunEvent::NodeSkipped { id } if id == "assembly_run")), + "the ledgered node is skipped, not re-run" + ); + } + + #[test] + fn loading_validates_thermal_documents_before_any_motion() { + let directory = tempfile::tempdir().unwrap(); + write_synthetic_wave(directory.path()); + let path = directory + .path() + .join("stations/odtc-1/assembly_thermocycle.odtc.json"); + let text = std::fs::read_to_string(&path).unwrap(); + // 240 °C is far outside any block envelope. + std::fs::write(&path, text.replace("37.0", "240.0")).unwrap(); + let error = match load_workcell_directory(directory.path()) { + Ok(_) => panic!("an unrunnable profile fails at load time"), + Err(error) => error, + }; + assert!( + format!("{error:#}").contains("envelope"), + "the error names the envelope: {error:#}" + ); + } +} diff --git a/crates/lab-scene/Cargo.toml b/crates/lab-scene/Cargo.toml new file mode 100644 index 0000000..7a2aeac --- /dev/null +++ b/crates/lab-scene/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "lab-scene" +description = "Scene graphs and 3D exports for Lab benches and workcells" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +base64 = "0.22" +lab-compiler = { workspace = true } +lab-runfmt = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } + +[lints] +workspace = true diff --git a/crates/lab-scene/src/dims.rs b/crates/lab-scene/src/dims.rs new file mode 100644 index 0000000..c54188b --- /dev/null +++ b/crates/lab-scene/src/dims.rs @@ -0,0 +1,97 @@ +//! Nominal extents for scene geometry. +//! +//! The planning catalog carries anchor points — rails, site offsets, well +//! centers — because that is what motion needs. Rendering needs bounding +//! boxes, so this table states them separately, as nominal visualization +//! dimensions: good enough to recognize the bench at a glance, never used +//! for planning. Positions stay exact either way. + +use lab_compiler::backend::hamilton::star::catalog::{ + self, CarrierDefinition, LabwareDefinition, LabwareLayout, +}; + +/// The SLAS/ANSI microplate footprint every plate and tip rack shares. +pub const SLAS_FOOTPRINT_X_MM: f64 = 127.76; +pub const SLAS_FOOTPRINT_Y_MM: f64 = 85.48; + +/// Nominal STAR carrier length, front to back. +pub const CARRIER_LENGTH_MM: f64 = 497.0; +/// Nominal carrier tray thickness. +pub const CARRIER_HEIGHT_MM: f64 = 18.0; +/// Nominal deck plate depth and thickness. +pub const DECK_DEPTH_MM: f64 = 600.0; +pub const DECK_THICKNESS_MM: f64 = 15.0; + +/// A carrier's footprint: exact width from its rail span, nominal length +/// and height. +pub fn carrier_extent(definition: &CarrierDefinition) -> [f64; 3] { + [ + f64::from(definition.width_rails) * catalog::RAIL_PITCH, + CARRIER_LENGTH_MM, + CARRIER_HEIGHT_MM, + ] +} + +/// A labware's footprint and height. Known catalog ids get stated +/// dimensions; anything else derives a footprint from its layout pitch so +/// new labware never renders as nothing. +pub fn labware_extent(definition: &LabwareDefinition) -> [f64; 3] { + match definition.id { + "tip_rack_50ul" => [SLAS_FOOTPRINT_X_MM, SLAS_FOOTPRINT_Y_MM, 60.0], + "tip_rack_300ul" => [SLAS_FOOTPRINT_X_MM, SLAS_FOOTPRINT_Y_MM, 72.0], + "tip_rack_1000ul" => [SLAS_FOOTPRINT_X_MM, SLAS_FOOTPRINT_Y_MM, 95.0], + "pcr_plate_96" => [SLAS_FOOTPRINT_X_MM, SLAS_FOOTPRINT_Y_MM, 16.1], + "sample_tubes_24" => [SLAS_FOOTPRINT_X_MM, SLAS_FOOTPRINT_Y_MM, 62.0], + "trough_60ml" => [35.0, 120.0, 45.0], + _ => derived_extent(definition), + } +} + +fn derived_extent(definition: &LabwareDefinition) -> [f64; 3] { + let height = definition + .vessel() + .map(|(bottom, depth, _, _)| bottom + depth + 3.0) + .unwrap_or(50.0); + match definition.layout { + LabwareLayout::Grid { + rows, + columns, + pitch, + .. + } => [ + (columns as f64 + 1.0) * pitch, + (rows as f64 + 1.0) * pitch, + height, + ], + LabwareLayout::Linear { + positions, spacing, .. + } => [(positions as f64 + 1.0) * spacing, 40.0, height], + LabwareLayout::Single { .. } => [40.0, 120.0, height], + } +} + +/// A well's cylinder diameter: exact when the catalog's height model is a +/// cylinder, otherwise a nominal fraction of the well pitch. +pub fn well_diameter(definition: &LabwareDefinition) -> f64 { + use lab_compiler::backend::hamilton::star::catalog::{HeightModel, LabwareRole}; + if let LabwareRole::Vessel { + height_model: HeightModel::Cylinder { diameter }, + .. + } = definition.role + { + return diameter; + } + match definition.layout { + LabwareLayout::Grid { pitch, .. } => pitch * 0.7, + LabwareLayout::Linear { spacing, .. } => spacing * 0.7, + LabwareLayout::Single { .. } => 25.0, + } +} + +/// A well's cylinder height: its vessel depth, or a tip's nominal length. +pub fn well_height(definition: &LabwareDefinition) -> f64 { + definition + .vessel() + .map(|(_, depth, _, _)| depth) + .unwrap_or(45.0) +} diff --git a/crates/lab-scene/src/gltf.rs b/crates/lab-scene/src/gltf.rs new file mode 100644 index 0000000..c29539d --- /dev/null +++ b/crates/lab-scene/src/gltf.rs @@ -0,0 +1,374 @@ +//! glTF 2.0 export: a derived projection of the scene for any standard +//! 3D viewer. Two unit meshes (a box and a cylinder) are instanced with +//! per-node scale, so the file stays small no matter how many wells a +//! deck has. +//! +//! The lab frame is Z-up millimeters; glTF is Y-up meters. The root node +//! carries the rotation and scale that map one to the other, so every +//! other transform in the file is the scene's own. + +use base64::Engine as _; +use serde_json::{Value, json}; + +use crate::scene::{Geometry, Scene, SceneNode, Semantic}; + +/// Renders the scene as a self-contained `.gltf` JSON document with its +/// buffer embedded as a data URI. +pub fn render_gltf(scene: &Scene) -> String { + let buffer = MeshBuffer::build(); + let mut nodes: Vec = Vec::new(); + + // Root: Z-up mm -> Y-up m. A -90 degree rotation about X, then a + // uniform 0.001 scale. + let root_index = 0usize; + nodes.push(json!({ + "name": scene.name, + "rotation": [-std::f64::consts::FRAC_1_SQRT_2, 0.0, 0.0, std::f64::consts::FRAC_1_SQRT_2], + "scale": [0.001, 0.001, 0.001], + "children": Vec::::new(), + })); + let top = emit_node(&scene.root, &mut nodes); + nodes[root_index]["children"] = json!([top]); + + let document = json!({ + "asset": { "version": "2.0", "generator": "lab-scene" }, + "scene": 0, + "scenes": [ { "name": scene.name, "nodes": [root_index] } ], + "nodes": nodes, + "meshes": [ + unit_mesh("unit-box", 0, 1, 2), + unit_mesh("unit-cylinder", 3, 4, 5), + ], + "materials": materials(), + "accessors": buffer.accessors, + "bufferViews": buffer.views, + "buffers": [ { + "byteLength": buffer.bytes.len(), + "uri": format!( + "data:application/octet-stream;base64,{}", + base64::engine::general_purpose::STANDARD.encode(&buffer.bytes) + ), + } ], + }); + serde_json::to_string_pretty(&document).expect("the glTF document serializes") +} + +/// Emits one scene node (and its geometry as a scaled child), returning +/// its index. +fn emit_node(node: &SceneNode, nodes: &mut Vec) -> usize { + let index = nodes.len(); + nodes.push(json!({ + "name": node.id, + "translation": [node.translation[0], node.translation[1], node.translation[2]], + })); + let mut children: Vec = Vec::new(); + if let Some(geometry) = &node.geometry { + let geometry_index = nodes.len(); + let (mesh, scale) = match geometry { + Geometry::Box { x, y, z } => (0, [*x, *y, *z]), + Geometry::Cylinder { diameter, height } => (1, [*diameter, *diameter, *height]), + }; + nodes.push(json!({ + "name": format!("{}#geometry", node.id), + "mesh": mesh, + "scale": scale, + "extras": { "material": material_index(&node.semantic, &node.id) }, + })); + children.push(geometry_index); + } + for child in &node.children { + children.push(emit_node(child, nodes)); + } + if !children.is_empty() { + nodes[index]["children"] = json!(children); + } + index +} + +fn unit_mesh(name: &str, position: usize, normal: usize, indices: usize) -> Value { + json!({ + "name": name, + "primitives": [ { + "attributes": { "POSITION": position, "NORMAL": normal }, + "indices": indices, + "material": 0, + } ], + }) +} + +fn materials() -> Value { + json!([ + { "name": "deck", "pbrMetallicRoughness": { "baseColorFactor": [0.55, 0.56, 0.58, 1.0], "roughnessFactor": 0.9 }, "doubleSided": true }, + { "name": "carrier", "pbrMetallicRoughness": { "baseColorFactor": [0.33, 0.34, 0.38, 1.0], "roughnessFactor": 0.8 }, "doubleSided": true }, + { "name": "plate", "pbrMetallicRoughness": { "baseColorFactor": [0.92, 0.92, 0.94, 1.0], "roughnessFactor": 0.5 }, "doubleSided": true }, + { "name": "tips", "pbrMetallicRoughness": { "baseColorFactor": [0.90, 0.60, 0.20, 1.0], "roughnessFactor": 0.6 }, "doubleSided": true }, + { "name": "well", "pbrMetallicRoughness": { "baseColorFactor": [0.30, 0.55, 0.90, 0.45], "roughnessFactor": 0.3 }, "alphaMode": "BLEND", "doubleSided": true }, + { "name": "station", "pbrMetallicRoughness": { "baseColorFactor": [0.62, 0.64, 0.68, 1.0], "roughnessFactor": 0.9 }, "doubleSided": true } + ]) +} + +/// The material a semantic renders with. Recorded in node extras; the +/// viewer applies it (glTF binds materials to primitives, and the two +/// shared unit meshes cannot carry per-instance materials themselves). +fn material_index(semantic: &Semantic, id: &str) -> usize { + match semantic { + Semantic::Deck => 0, + Semantic::Carrier { .. } => 1, + Semantic::Labware { catalog } if catalog.contains("tip") => 3, + Semantic::Labware { .. } => 2, + Semantic::Well { .. } if id.contains("tip") => 3, + Semantic::Well { .. } => 4, + Semantic::Room | Semantic::Station { .. } | Semantic::Site { .. } => 5, + } +} + +/// The shared vertex data: a unit box (minimum corner at the origin) and +/// a unit cylinder (diameter 1, height 1, standing on the origin). +struct MeshBuffer { + bytes: Vec, + views: Value, + accessors: Value, +} + +impl MeshBuffer { + fn build() -> Self { + let (box_positions, box_normals, box_indices) = unit_box(); + let (cyl_positions, cyl_normals, cyl_indices) = unit_cylinder(24); + + let mut bytes: Vec = Vec::new(); + let mut views: Vec = Vec::new(); + let mut accessors: Vec = Vec::new(); + + let push_f32 = |data: &[[f32; 3]], + bytes: &mut Vec, + views: &mut Vec, + accessors: &mut Vec| { + let offset = bytes.len(); + for vertex in data { + for component in vertex { + bytes.extend_from_slice(&component.to_le_bytes()); + } + } + let (min, max) = bounds(data); + views.push(json!({ "buffer": 0, "byteOffset": offset, "byteLength": data.len() * 12, "target": 34962 })); + accessors.push(json!({ + "bufferView": views.len() - 1, "componentType": 5126, "count": data.len(), + "type": "VEC3", "min": min, "max": max, + })); + }; + let push_u16 = |data: &[u16], + bytes: &mut Vec, + views: &mut Vec, + accessors: &mut Vec| { + // Index views must be 4-byte alignable after f32 views; u16 is + // 2-aligned, which the f32 runs already guarantee. + let offset = bytes.len(); + for index in data { + bytes.extend_from_slice(&index.to_le_bytes()); + } + if !bytes.len().is_multiple_of(4) { + bytes.extend_from_slice(&[0, 0]); + } + views.push(json!({ "buffer": 0, "byteOffset": offset, "byteLength": data.len() * 2, "target": 34963 })); + accessors.push(json!({ + "bufferView": views.len() - 1, "componentType": 5123, "count": data.len(), + "type": "SCALAR", + })); + }; + + push_f32(&box_positions, &mut bytes, &mut views, &mut accessors); + push_f32(&box_normals, &mut bytes, &mut views, &mut accessors); + push_u16(&box_indices, &mut bytes, &mut views, &mut accessors); + push_f32(&cyl_positions, &mut bytes, &mut views, &mut accessors); + push_f32(&cyl_normals, &mut bytes, &mut views, &mut accessors); + push_u16(&cyl_indices, &mut bytes, &mut views, &mut accessors); + + Self { + bytes, + views: Value::Array(views), + accessors: Value::Array(accessors), + } + } +} + +fn bounds(data: &[[f32; 3]]) -> (Vec, Vec) { + let mut min = [f32::MAX; 3]; + let mut max = [f32::MIN; 3]; + for vertex in data { + for axis in 0..3 { + min[axis] = min[axis].min(vertex[axis]); + max[axis] = max[axis].max(vertex[axis]); + } + } + (min.to_vec(), max.to_vec()) +} + +/// 24 vertices (per-face normals), 36 indices, min corner at the origin. +#[allow(clippy::type_complexity)] +fn unit_box() -> (Vec<[f32; 3]>, Vec<[f32; 3]>, Vec) { + let faces: [([f32; 3], [[f32; 3]; 4]); 6] = [ + // -Z + ( + [0.0, 0.0, -1.0], + [ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [1.0, 1.0, 0.0], + [0.0, 1.0, 0.0], + ], + ), + // +Z + ( + [0.0, 0.0, 1.0], + [ + [0.0, 0.0, 1.0], + [1.0, 0.0, 1.0], + [1.0, 1.0, 1.0], + [0.0, 1.0, 1.0], + ], + ), + // -Y + ( + [0.0, -1.0, 0.0], + [ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [1.0, 0.0, 1.0], + [0.0, 0.0, 1.0], + ], + ), + // +Y + ( + [0.0, 1.0, 0.0], + [ + [0.0, 1.0, 0.0], + [1.0, 1.0, 0.0], + [1.0, 1.0, 1.0], + [0.0, 1.0, 1.0], + ], + ), + // -X + ( + [-1.0, 0.0, 0.0], + [ + [0.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 1.0, 1.0], + [0.0, 0.0, 1.0], + ], + ), + // +X + ( + [1.0, 0.0, 0.0], + [ + [1.0, 0.0, 0.0], + [1.0, 1.0, 0.0], + [1.0, 1.0, 1.0], + [1.0, 0.0, 1.0], + ], + ), + ]; + let mut positions = Vec::with_capacity(24); + let mut normals = Vec::with_capacity(24); + let mut indices = Vec::with_capacity(36); + for (normal, corners) in faces { + let base = positions.len() as u16; + positions.extend_from_slice(&corners); + normals.extend([normal; 4]); + indices.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]); + } + (positions, normals, indices) +} + +/// A cylinder of diameter and height 1 standing on the origin: smooth +/// side normals, flat caps. +#[allow(clippy::type_complexity)] +fn unit_cylinder(segments: usize) -> (Vec<[f32; 3]>, Vec<[f32; 3]>, Vec) { + let mut positions = Vec::new(); + let mut normals = Vec::new(); + let mut indices = Vec::new(); + + // Side: paired bottom/top vertices around the rim. + for segment in 0..=segments { + let angle = std::f32::consts::TAU * segment as f32 / segments as f32; + let (sin, cos) = angle.sin_cos(); + positions.push([0.5 * cos, 0.5 * sin, 0.0]); + positions.push([0.5 * cos, 0.5 * sin, 1.0]); + normals.push([cos, sin, 0.0]); + normals.push([cos, sin, 0.0]); + } + for segment in 0..segments { + let a = (segment * 2) as u16; + indices.extend_from_slice(&[a, a + 2, a + 1, a + 1, a + 2, a + 3]); + } + + // Caps: a center vertex and a rim per cap. + for (z, normal_z) in [(0.0f32, -1.0f32), (1.0, 1.0)] { + let center = positions.len() as u16; + positions.push([0.0, 0.0, z]); + normals.push([0.0, 0.0, normal_z]); + let rim_start = positions.len() as u16; + for segment in 0..segments { + let angle = std::f32::consts::TAU * segment as f32 / segments as f32; + let (sin, cos) = angle.sin_cos(); + positions.push([0.5 * cos, 0.5 * sin, z]); + normals.push([0.0, 0.0, normal_z]); + } + for segment in 0..segments { + let a = rim_start + segment as u16; + let b = rim_start + ((segment + 1) % segments) as u16; + if normal_z > 0.0 { + indices.extend_from_slice(&[center, a, b]); + } else { + indices.extend_from_slice(&[center, b, a]); + } + } + } + (positions, normals, indices) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::scene::{SCENE_FORMAT, SceneNode, Semantic}; + + #[test] + fn the_gltf_document_is_structurally_sound() { + let scene = Scene { + format: SCENE_FORMAT.to_string(), + name: "test".to_string(), + root: SceneNode::new("room", Semantic::Room, [0.0, 0.0, 0.0]).with_geometry( + Geometry::Box { + x: 100.0, + y: 100.0, + z: 10.0, + }, + ), + }; + let text = render_gltf(&scene); + let document: serde_json::Value = serde_json::from_str(&text).unwrap(); + assert_eq!(document["asset"]["version"], "2.0"); + assert_eq!(document["meshes"].as_array().unwrap().len(), 2); + let accessor_count = document["accessors"].as_array().unwrap().len(); + assert_eq!(accessor_count, 6, "positions, normals, indices per mesh"); + // Every node child index is in range. + let nodes = document["nodes"].as_array().unwrap(); + for node in nodes { + if let Some(children) = node["children"].as_array() { + for child in children { + assert!((child.as_u64().unwrap() as usize) < nodes.len()); + } + } + } + // The buffer decodes and matches its declared length. + let uri = document["buffers"][0]["uri"].as_str().unwrap(); + let encoded = uri.split(',').nth(1).unwrap(); + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded) + .unwrap(); + assert_eq!( + bytes.len() as u64, + document["buffers"][0]["byteLength"].as_u64().unwrap() + ); + } +} diff --git a/crates/lab-scene/src/lib.rs b/crates/lab-scene/src/lib.rs new file mode 100644 index 0000000..34ac743 --- /dev/null +++ b/crates/lab-scene/src/lib.rs @@ -0,0 +1,22 @@ +//! Scene graphs for Lab benches and workcells: the geometry the compiler +//! plans against, arranged as a tree a renderer can draw. +//! +//! The scene is the semantic source of truth (`lab.scene.v0`); glTF and +//! USD files are derived projections of it. A viewer binds simulation +//! trace events to scene nodes by id — labware nodes carry the same +//! resource names the plans and traces use — and computes nothing itself. +//! +//! Geometry comes in two grades and the scene is honest about which is +//! which: positions are exact, taken from the same catalog the planner +//! uses; extents (footprints, heights) are nominal visualization +//! dimensions from a side table, because the planning catalog carries +//! anchor points, not bounding boxes. + +pub mod dims; +pub mod gltf; +pub mod scene; +pub mod star; +pub mod usda; +pub mod workcell; + +pub use scene::{Geometry, Scene, SceneError, SceneNode, Semantic}; diff --git a/crates/lab-scene/src/scene.rs b/crates/lab-scene/src/scene.rs new file mode 100644 index 0000000..143b643 --- /dev/null +++ b/crates/lab-scene/src/scene.rs @@ -0,0 +1,109 @@ +//! `lab.scene.v0`: the neutral scene graph document. +//! +//! Coordinates are millimeters in the lab frame: X right along the deck, +//! Y toward the back, Z up. A node's translation is relative to its +//! parent. A `Box` sits with its minimum corner at the node origin; a +//! `Cylinder` stands on the node origin, centered on it. + +use serde::{Deserialize, Serialize}; + +/// The format string every `lab.scene.v0` document declares. +pub const SCENE_FORMAT: &str = "lab.scene.v0"; + +#[derive(Debug, thiserror::Error)] +pub enum SceneError { + #[error("the profile places carrier '{name}' as '{catalog}', which the catalog does not have")] + UnknownCarrier { name: String, catalog: String }, + #[error("the profile loads labware '{labware}', which the catalog does not have")] + UnknownLabware { labware: String }, + #[error("'{address}' is not a / address")] + BadSiteAddress { address: String }, + #[error("{context}: {message}")] + Resolve { context: String, message: String }, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Scene { + /// Always [`SCENE_FORMAT`]; readers reject any other value. + pub format: String, + /// What was rendered: a target name, facility name, or wave. + pub name: String, + pub root: SceneNode, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct SceneNode { + /// Stable identity a trace event can bind to. Labware nodes use the + /// plan's resource names (`reaction_plate`, `dna_plate/1`). + pub id: String, + pub semantic: Semantic, + /// Millimeters, relative to the parent node. + pub translation: [f64; 3], + #[serde(default, skip_serializing_if = "Option::is_none")] + pub geometry: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub children: Vec, +} + +impl SceneNode { + pub fn new(id: impl Into, semantic: Semantic, translation: [f64; 3]) -> Self { + Self { + id: id.into(), + semantic, + translation, + geometry: None, + children: Vec::new(), + } + } + + pub fn with_geometry(mut self, geometry: Geometry) -> Self { + self.geometry = Some(geometry); + self + } + + /// Depth-first traversal over the node and everything under it. + pub fn walk(&self, visit: &mut dyn FnMut(&SceneNode, [f64; 3])) { + fn inner(node: &SceneNode, origin: [f64; 3], visit: &mut dyn FnMut(&SceneNode, [f64; 3])) { + let here = [ + origin[0] + node.translation[0], + origin[1] + node.translation[1], + origin[2] + node.translation[2], + ]; + visit(node, here); + for child in &node.children { + inner(child, here, visit); + } + } + inner(self, [0.0, 0.0, 0.0], visit); + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "kebab-case")] +pub enum Semantic { + /// The room a workcell's stations stand in. + Room, + /// One instrument, by its station kind string. + Station { station_kind: String }, + /// A liquid handler's deck plate. + Deck, + /// A carrier seated on rails, by catalog id. + Carrier { catalog: String }, + /// One seat on a carrier, 0-based. + Site { index: usize }, + /// A piece of labware, by catalog id. + Labware { catalog: String }, + /// One well or tip position within its labware. + Well { name: String }, +} + +/// Extents in millimeters. Positions in the scene are exact; extents are +/// nominal (see `dims`). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "shape", rename_all = "kebab-case")] +pub enum Geometry { + /// Minimum corner at the node origin. + Box { x: f64, y: f64, z: f64 }, + /// Standing on the node origin, centered on it. + Cylinder { diameter: f64, height: f64 }, +} diff --git a/crates/lab-scene/src/star.rs b/crates/lab-scene/src/star.rs new file mode 100644 index 0000000..0dc27b9 --- /dev/null +++ b/crates/lab-scene/src/star.rs @@ -0,0 +1,321 @@ +//! The STAR deck scene: the same composition chain the planner uses — +//! deck origin, carrier at rail, site offset, labware layout, well — as a +//! node tree. +//! +//! Positions are exact: every well's local offset is derived from the +//! catalog's own `well_position` composition, never re-derived here. +//! Labware nodes carry the plan's resource names so trace events bind to +//! them directly. + +use std::collections::BTreeMap; + +use lab_compiler::backend::hamilton::star::catalog::{ + self, CARRIER_ORIGIN_Y, CARRIER_ORIGIN_Z, RAIL_ONE_X, RAIL_PITCH, +}; +use lab_compiler::backend::hamilton::star::profile::StarTargetProfile; + +use crate::dims; +use crate::scene::{Geometry, SceneError, SceneNode, Semantic}; + +/// One physical placement the profile claims: the plan's resource name, +/// the `/` address, and the labware catalog id. +struct Placement { + resource: String, + address: String, + labware: String, +} + +/// Every labware placement a profile makes, under the same resource-name +/// convention the planner's deck index uses (`dna_plate/1`, ...). The two +/// deck-level placements keep their field names. +fn placements(profile: &StarTargetProfile) -> Vec { + let mut out = vec![ + Placement { + resource: "source_rack".to_string(), + address: profile.deck.source_rack.site.clone(), + labware: profile.deck.source_rack.labware.clone(), + }, + Placement { + resource: "reaction_plate".to_string(), + address: profile.deck.reaction_plate.site.clone(), + labware: profile.deck.reaction_plate.labware.clone(), + }, + ]; + fn family(out: &mut Vec, prefix: &str, labware: &str, slots: &[String]) { + for (index, slot) in slots.iter().enumerate() { + out.push(Placement { + resource: format!("{prefix}/{}", index + 1), + address: slot.clone(), + labware: labware.to_string(), + }); + } + } + let stages = &profile.stages; + family( + &mut out, + "assembly_small_tips", + &stages.assembly.small_tips.labware, + &stages.assembly.small_tips.slots, + ); + family( + &mut out, + "dna_plate", + &stages.transformation.dna_plate.labware, + &stages.transformation.dna_plate.slots, + ); + family( + &mut out, + "transformation_small_tips", + &stages.transformation.small_tips.labware, + &stages.transformation.small_tips.slots, + ); + family( + &mut out, + "transformation_large_tips", + &stages.transformation.large_tips.labware, + &stages.transformation.large_tips.slots, + ); + family( + &mut out, + "dilution_plate", + &stages.plating.dilution_plate.labware, + &stages.plating.dilution_plate.slots, + ); + family( + &mut out, + "agar_plate", + &stages.plating.agar_plate.labware, + &stages.plating.agar_plate.slots, + ); + family( + &mut out, + "plating_small_tips", + &stages.plating.small_tips.labware, + &stages.plating.small_tips.slots, + ); + family( + &mut out, + "plating_large_tips", + &stages.plating.large_tips.labware, + &stages.plating.large_tips.slots, + ); + out.push(Placement { + resource: "media_rack".to_string(), + address: stages.plating.media_rack.slot.clone(), + labware: stages.plating.media_rack.labware.clone(), + }); + out +} + +/// Builds the deck node for one STAR bench: deck plate, carriers at their +/// rails, sites, labware with every well as a child cylinder. +pub fn star_deck_scene(profile: &StarTargetProfile) -> Result { + let rails = f64::from(profile.machine.variant.rails()); + let mut deck = SceneNode::new( + "deck", + Semantic::Deck, + [ + RAIL_ONE_X - RAIL_PITCH, + CARRIER_ORIGIN_Y - 40.0, + CARRIER_ORIGIN_Z - dims::DECK_THICKNESS_MM, + ], + ) + .with_geometry(Geometry::Box { + x: (rails + 2.0) * RAIL_PITCH, + y: dims::DECK_DEPTH_MM, + z: dims::DECK_THICKNESS_MM, + }); + // The deck node's children are positioned in the lab frame, so undo + // the deck plate's own offset once instead of at every child. + let deck_origin = deck.translation; + + // Carrier nodes, keyed by the profile-local carrier name. + let mut carriers: BTreeMap = BTreeMap::new(); + for (name, placement) in &profile.deck.carriers { + let definition = + catalog::carrier(&placement.catalog).ok_or_else(|| SceneError::UnknownCarrier { + name: name.clone(), + catalog: placement.catalog.clone(), + })?; + let origin = [ + RAIL_ONE_X + f64::from(placement.rail - 1) * RAIL_PITCH, + CARRIER_ORIGIN_Y, + CARRIER_ORIGIN_Z, + ]; + let extent = dims::carrier_extent(definition); + let node = SceneNode::new( + name.clone(), + Semantic::Carrier { + catalog: placement.catalog.clone(), + }, + [ + origin[0] - deck_origin[0], + origin[1] - deck_origin[1], + origin[2] - deck_origin[2], + ], + ) + .with_geometry(Geometry::Box { + x: extent[0], + y: extent[1], + z: dims::CARRIER_HEIGHT_MM, + }); + carriers.insert(name.clone(), node); + } + + // Labware onto carrier sites. Two stage aliases can claim one physical + // site (the source rack serves assembly and transformation), so the + // first claim renders and the rest are the same object. + let mut occupied: BTreeMap<(String, usize), String> = BTreeMap::new(); + for placement in placements(profile) { + let Some((carrier_name, _)) = placement.address.split_once('/') else { + return Err(SceneError::BadSiteAddress { + address: placement.address.clone(), + }); + }; + let resolved = profile + .resolve_labware(&placement.resource, &placement.address, &placement.labware) + .map_err(|error| SceneError::Resolve { + context: placement.resource.clone(), + message: error.to_string(), + })?; + if occupied + .insert( + (carrier_name.to_string(), resolved.site), + placement.resource.clone(), + ) + .is_some() + { + continue; + } + + let site_offset = resolved.carrier.sites[resolved.site]; + let labware_extent = dims::labware_extent(resolved.labware); + let mut labware_node = SceneNode::new( + placement.resource.clone(), + Semantic::Labware { + catalog: resolved.labware.id.to_string(), + }, + [site_offset.x, site_offset.y, site_offset.z], + ) + .with_geometry(Geometry::Box { + x: labware_extent[0], + y: labware_extent[1], + z: labware_extent[2], + }); + + // Well positions come from the catalog's own composition: the + // absolute position minus the labware origin is the local offset. + let labware_origin = [ + RAIL_ONE_X + f64::from(resolved.rail - 1) * RAIL_PITCH + site_offset.x, + CARRIER_ORIGIN_Y + site_offset.y, + CARRIER_ORIGIN_Z + site_offset.z, + ]; + if let Some((rows, columns)) = catalog::grid_for_capacity(resolved.labware.capacity) { + let diameter = dims::well_diameter(resolved.labware); + let height = dims::well_height(resolved.labware); + for column in 0..columns { + for row in 0..rows { + let name = format!("{}{}", (b'A' + row as u8) as char, column + 1); + let Some(position) = resolved.well(&name) else { + continue; + }; + labware_node.children.push( + SceneNode::new( + format!("{}:{name}", placement.resource), + Semantic::Well { name: name.clone() }, + [ + position.x - labware_origin[0], + position.y - labware_origin[1], + position.z - labware_origin[2], + ], + ) + .with_geometry(Geometry::Cylinder { diameter, height }), + ); + } + } + } + + let site_node = SceneNode::new( + format!("{}:site-{}", carrier_name, resolved.site + 1), + Semantic::Site { + index: resolved.site, + }, + [0.0, 0.0, 0.0], + ); + let carrier_node = + carriers + .get_mut(carrier_name) + .ok_or_else(|| SceneError::BadSiteAddress { + address: placement.address.clone(), + })?; + let mut site_node = site_node; + site_node.children.push(labware_node); + carrier_node.children.push(site_node); + } + + deck.children.extend(carriers.into_values()); + Ok(deck) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::scene::Semantic; + + const PROFILE: &str = include_str!("../../../examples/golden-gate/targets/hamilton-star.toml"); + + #[test] + fn the_scene_reproduces_the_catalogs_well_positions_exactly() { + let profile = StarTargetProfile::parse("hamilton-star", PROFILE).unwrap(); + let deck = star_deck_scene(&profile).unwrap(); + + // Find the reaction plate's A1 well by accumulating translations. + let mut found = None; + deck.walk(&mut |node, origin| { + if node.id == "reaction_plate:A1" { + found = Some(origin); + } + }); + let scene_a1 = found.expect("the reaction plate renders its wells"); + + let resolved = profile + .resolve_labware( + "test", + &profile.deck.reaction_plate.site, + &profile.deck.reaction_plate.labware, + ) + .unwrap(); + let expected = resolved.well("A1").unwrap(); + // The deck node's children live in the lab frame; walk() started + // from the deck's own translation, so origins are absolute. + assert!( + (scene_a1[0] - expected.x).abs() < 1e-9 + && (scene_a1[1] - expected.y).abs() < 1e-9 + && (scene_a1[2] - expected.z).abs() < 1e-9, + "scene {scene_a1:?} vs catalog ({}, {}, {})", + expected.x, + expected.y, + expected.z + ); + } + + #[test] + fn every_placement_renders_once_with_plan_resource_names() { + let profile = StarTargetProfile::parse("hamilton-star", PROFILE).unwrap(); + let deck = star_deck_scene(&profile).unwrap(); + let mut labware_ids = Vec::new(); + deck.walk(&mut |node, _| { + if matches!(node.semantic, Semantic::Labware { .. }) { + labware_ids.push(node.id.clone()); + } + }); + assert!( + labware_ids.contains(&"reaction_plate".to_string()), + "plan resource names are node ids: {labware_ids:?}" + ); + let mut sorted = labware_ids.clone(); + sorted.sort(); + sorted.dedup(); + assert_eq!(sorted.len(), labware_ids.len(), "no site renders twice"); + } +} diff --git a/crates/lab-scene/src/usda.rs b/crates/lab-scene/src/usda.rs new file mode 100644 index 0000000..11c2b82 --- /dev/null +++ b/crates/lab-scene/src/usda.rs @@ -0,0 +1,178 @@ +//! USD export: a plain-text `.usda` layer for Isaac Sim, Omniverse, and +//! Unreal. Same scene, same millimeters; USD carries the unit and up-axis +//! declaration itself, so no root transform is needed. + +use std::fmt::Write; + +use crate::scene::{Geometry, Scene, SceneNode}; + +/// Renders the scene as a `.usda` text layer: one `Xform` per node, with +/// `Cube`/`Cylinder` geometry prims scaled to the stated extents. +pub fn render_usda(scene: &Scene) -> String { + let mut text = String::new(); + let _ = writeln!( + text, + "#usda 1.0\n(\n defaultPrim = \"{}\"\n metersPerUnit = 0.001\n upAxis = \"Z\"\n)\n", + prim_name(&scene.root.id) + ); + emit_node(&scene.root, 0, &mut text); + text +} + +/// USD prim names are identifiers; scene ids carry `/`, `:`, `#`. +fn prim_name(id: &str) -> String { + let mut name: String = id + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || character == '_' { + character + } else { + '_' + } + }) + .collect(); + if name + .chars() + .next() + .is_none_or(|first| first.is_ascii_digit()) + { + name.insert(0, '_'); + } + name +} + +fn indent(text: &mut String, depth: usize) { + for _ in 0..depth { + text.push_str(" "); + } +} + +fn emit_node(node: &SceneNode, depth: usize, text: &mut String) { + indent(text, depth); + let _ = writeln!(text, "def Xform \"{}\" (", prim_name(&node.id)); + indent(text, depth); + let _ = writeln!( + text, + " customData = {{ string labId = \"{}\" }}", + node.id + ); + indent(text, depth); + let _ = writeln!(text, ")"); + indent(text, depth); + let _ = writeln!(text, "{{"); + indent(text, depth + 1); + let _ = writeln!( + text, + "double3 xformOp:translate = ({}, {}, {})", + node.translation[0], node.translation[1], node.translation[2] + ); + indent(text, depth + 1); + let _ = writeln!( + text, + "uniform token[] xformOpOrder = [\"xformOp:translate\"]" + ); + + if let Some(geometry) = &node.geometry { + match geometry { + Geometry::Box { x, y, z } => { + // A USD Cube spans [-size/2, size/2]; scale a unit cube and + // seat its minimum corner on the node origin. + indent(text, depth + 1); + let _ = writeln!(text, "def Cube \"geometry\""); + indent(text, depth + 1); + let _ = writeln!(text, "{{"); + indent(text, depth + 2); + let _ = writeln!(text, "double size = 1"); + indent(text, depth + 2); + let _ = writeln!( + text, + "double3 xformOp:translate = ({}, {}, {})", + x / 2.0, + y / 2.0, + z / 2.0 + ); + indent(text, depth + 2); + let _ = writeln!(text, "float3 xformOp:scale = ({x}, {y}, {z})"); + indent(text, depth + 2); + let _ = writeln!( + text, + "uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:scale\"]" + ); + indent(text, depth + 1); + let _ = writeln!(text, "}}"); + } + Geometry::Cylinder { diameter, height } => { + indent(text, depth + 1); + let _ = writeln!(text, "def Cylinder \"geometry\""); + indent(text, depth + 1); + let _ = writeln!(text, "{{"); + indent(text, depth + 2); + let _ = writeln!(text, "uniform token axis = \"Z\""); + indent(text, depth + 2); + let _ = writeln!(text, "double height = {height}"); + indent(text, depth + 2); + let _ = writeln!(text, "double radius = {}", diameter / 2.0); + indent(text, depth + 2); + let _ = writeln!(text, "double3 xformOp:translate = (0, 0, {})", height / 2.0); + indent(text, depth + 2); + let _ = writeln!( + text, + "uniform token[] xformOpOrder = [\"xformOp:translate\"]" + ); + indent(text, depth + 1); + let _ = writeln!(text, "}}"); + } + } + } + + for child in &node.children { + emit_node(child, depth + 1, text); + } + indent(text, depth); + let _ = writeln!(text, "}}"); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::scene::{SCENE_FORMAT, Semantic}; + + #[test] + fn the_layer_declares_units_and_sanitizes_prim_names() { + let scene = Scene { + format: SCENE_FORMAT.to_string(), + name: "test".to_string(), + root: SceneNode { + id: "room".to_string(), + semantic: Semantic::Room, + translation: [0.0, 0.0, 0.0], + geometry: None, + children: vec![ + SceneNode::new( + "dna_plate/1:A1", + Semantic::Well { + name: "A1".to_string(), + }, + [1.0, 2.0, 3.0], + ) + .with_geometry(Geometry::Cylinder { + diameter: 6.0, + height: 10.0, + }), + ], + }, + }; + let text = render_usda(&scene); + assert!(text.starts_with("#usda 1.0")); + assert!(text.contains("metersPerUnit = 0.001")); + assert!(text.contains("upAxis = \"Z\"")); + assert!( + text.contains("def Xform \"dna_plate_1_A1\""), + "ids sanitize to identifiers:\n{text}" + ); + assert!( + text.contains("string labId = \"dna_plate/1:A1\""), + "the original id survives as customData" + ); + } +} diff --git a/crates/lab-scene/src/workcell.rs b/crates/lab-scene/src/workcell.rs new file mode 100644 index 0000000..5f43a33 --- /dev/null +++ b/crates/lab-scene/src/workcell.rs @@ -0,0 +1,88 @@ +//! The workcell room scene: stations in a row, each with what geometry it +//! has. The liquid handler nests its full deck; instruments without a +//! geometry catalog render as labeled, nominally-dimensioned boxes, which +//! is the asset registry's last-resort tier working as intended. + +use lab_compiler::backend::hamilton::star::profile::StarTargetProfile; + +use crate::scene::{Geometry, SCENE_FORMAT, Scene, SceneError, SceneNode, Semantic}; +use crate::star::star_deck_scene; + +/// Nominal gap between station origins along the room's x axis. +const STATION_PITCH_MM: f64 = 1600.0; +/// Nominal bench height stations stand at. +const BENCH_TOP_MM: f64 = 900.0; + +/// One station to place: its name, its kind string, and its STAR profile +/// when it is a liquid handler. +pub struct StationScene { + pub name: String, + pub kind: String, + pub star_profile: Option, +} + +/// Nominal instrument body extents by station kind. +fn station_body(kind: &str) -> Geometry { + match kind { + // The STAR renders as a plinth under its deck, so the deck's + // carriers and labware stay visible above it. + "hamilton.star" => Geometry::Box { + x: 1400.0, + y: 700.0, + z: 60.0, + }, + "inheco.odtc" => Geometry::Box { + x: 200.0, + y: 320.0, + z: 260.0, + }, + "byonoy.absorbance96" => Geometry::Box { + x: 160.0, + y: 240.0, + z: 120.0, + }, + _ => Geometry::Box { + x: 400.0, + y: 400.0, + z: 400.0, + }, + } +} + +/// Builds the room scene for a set of stations, in declaration order. +pub fn workcell_scene(name: &str, stations: Vec) -> Result { + let mut room = SceneNode::new("room", Semantic::Room, [0.0, 0.0, 0.0]); + for (index, station) in stations.into_iter().enumerate() { + let mut node = SceneNode::new( + station.name.clone(), + Semantic::Station { + station_kind: station.kind.clone(), + }, + [index as f64 * STATION_PITCH_MM, 0.0, BENCH_TOP_MM], + ) + .with_geometry(station_body(&station.kind)); + if let Some(profile) = &station.star_profile { + // The deck scene is in the machine's own frame, which shares + // the station node's origin. + node.children.push(star_deck_scene(profile)?); + } + room.children.push(node); + } + Ok(Scene { + format: SCENE_FORMAT.to_string(), + name: name.to_string(), + root: room, + }) +} + +/// A single bench is a room with one station. +pub fn star_bench_scene(name: &str, profile: &StarTargetProfile) -> Result { + workcell_scene( + name, + vec![StationScene { + name: "star".to_string(), + kind: "hamilton.star".to_string(), + star_profile: Some(profile.clone()), + }], + ) +} diff --git a/docs/README.md b/docs/README.md index ef90042..0128f67 100644 --- a/docs/README.md +++ b/docs/README.md @@ -60,6 +60,9 @@ Decision records preserve the reasoning and status behind the language rather th | [0030: Reviewed frames are the execution boundary](language/decisions/0030-reviewed-frames-are-the-execution-boundary.md) | the runtime interprets reviewed run documents and never plans | | [0031: Workcell targets](language/decisions/0031-workcell-targets.md) | a workcell target composes stations; assignment is planning, not language | | [0032: Provenance blocks](language/decisions/0032-provenance-blocks.md) | a provenance verb can open a block | +| [0033: Typeset protocol documents](language/decisions/0033-typeset-protocol-documents.md) | protocol documents are typeset PDFs emitted beside their sources | +| [0034: The simulator is an interpreter](language/decisions/0034-the-simulator-is-an-interpreter.md) | `lab simulate` interprets the same run documents `lab run` executes; the trace is the visualization contract | +| [0035: Facility files](language/decisions/0035-facility-files.md) | a facility is its own file under `facilities/`; the manifest carries at most a pointer | ## Implementation and embedding diff --git a/docs/language/decisions/0034-the-simulator-is-an-interpreter.md b/docs/language/decisions/0034-the-simulator-is-an-interpreter.md new file mode 100644 index 0000000..84630ff --- /dev/null +++ b/docs/language/decisions/0034-the-simulator-is-an-interpreter.md @@ -0,0 +1,50 @@ +# 0034 — The simulator is an interpreter, not a backend + +## Status + +Accepted. + +## Context + +A run document is already interpreted two ways: `lab run` executes it +against hardware, and `lab run --dry-run` validates and narrates it without +hardware. Simulation adds a third need: given the same documents, predict +how the work unfolds in time — total duration, when an operator must be +present, when they can walk away — and record the physical state changes a +visualization can play back. + +Two architectures could provide this. A simulation *backend* would compile +the checked program into its own execution model; a simulation +*interpreter* consumes the run documents the real runner consumes. The +backend shape invites drift: two lowerings of the same experiment can +disagree, and the simulation stops being evidence about the artifact the +operator will actually approve. + +## Decision + +`lab simulate` is a third interpreter of the emitted run documents. It +loads exactly what `lab run` loads, executes the same node walk against +simulated stations and a virtual clock, and adds nothing but time and +recorded state. It emits no run documents and compiles nothing, so it is +not a backend and puts no pressure on the closed backend dispatch of 0029. + +The run-document schemas move to their own crate, `lab-runfmt`, shared by +the emitters in `lab-compiler` and every interpreter. Loaders there check +each document's `format` string once, in one place. + +The simulator's output is a trace document, `lab.sim-trace.v0`: virtual +timestamps, node lifecycle, labware movements, instrument state, and the +intervals that require an operator. The trace is the contract for all +visualization; a viewer plays traces and computes nothing. Durations come +from a stated model — thermal profiles are computed exactly from the +document, robot steps and human steps are estimates the model labels as +such — never from hidden assumptions inside a renderer. + +## Consequences + +Simulation and execution cannot disagree about what the experiment *is*, +only about how long it takes; the timing model is data, so measured run +ledgers can calibrate it later. Any new run-document format gains +simulation by gaining an interpreter arm, not a parallel model. The trace +format is versioned like the run formats: a change to what an event means +is a new version, not an edit. diff --git a/docs/language/decisions/0035-facility-files.md b/docs/language/decisions/0035-facility-files.md new file mode 100644 index 0000000..bbf2075 --- /dev/null +++ b/docs/language/decisions/0035-facility-files.md @@ -0,0 +1,52 @@ +# 0035 — A facility is its own file, pointed to by the manifest + +## Status + +Accepted, partially implemented. + +## Context + +Simulation raises a question compilation never had to answer: what lab is +this experiment running in? A workcell target profile describes one bench; +the facility question is wider — which stations exist at all, what stock +sits in which fridge, what consumables are on the shelf, and how labware +travels between benches. Designing a new lab or a biofoundry is answering +exactly that question, so the answer needs to be a reviewable artifact. + +The relationship between packages and facilities is many-to-many: one +package is simulated against several candidate facilities to compare them, +and one facility serves every package that runs in that lab. The two also +change on different cadences — a manifest versions with the experiment; a +facility changes when the lab buys a freezer, and its stock changes daily. + +## Decision + +A facility is described in its own TOML file under `facilities/`, +validated by the runtime: named stations in the same vocabulary workcell +profiles use, storage units with the stock they hold, consumables, and a +transport section whose `walk_seconds` states how long a human handoff +takes in this facility. `lab simulate --facility` checks that every +station a plan needs exists there by name and kind, and drives handoff +durations from the facility's transport time. + +The manifest carries at most a pointer: `[build] facility = "main-bench"`, +a bare name held to the same no-path-escape rule as `[build] target`. The +description never lives in `lab.toml`, and station addresses stay runtime +input either way. + +Stock is inventory state, not a declaration (0026's companion rule): +storage units name materials and artifacts by their declared identities. +A build drawing on facility stock narrows it to the dependency graph's +demands first (`BuildInventory::restricted_to`), because resolution +deliberately rejects surplus stock in a package manifest and a facility +legitimately stocks a whole lab. + +## Consequences + +Comparing lab designs is running the same simulation twice with different +`--facility` files and reading two summaries. The facility file is the +seed for everything spatial that follows: room positions for stations, +scene generation, and the environment an arm policy trains in all attach +here, without touching packages or profiles. Wiring facility stock into +`lab build` dependency resolution remains open until the build command +grows a `--facility` flag of its own. diff --git a/examples/golden-gate/facilities/main-bench.toml b/examples/golden-gate/facilities/main-bench.toml new file mode 100644 index 0000000..099287b --- /dev/null +++ b/examples/golden-gate/facilities/main-bench.toml @@ -0,0 +1,34 @@ +# The lab this package usually runs in: the workcell-star bench plus the +# cold storage its stock lives in. One facility serves every package that +# runs here; a package's manifest carries at most a pointer to it, and +# `lab simulate --facility` compares candidate facilities by simulating the +# same build against each. + +[facility] +name = "main-bench" + +[[station]] +name = "star-1" +kind = "hamilton.star" +profile = "hamilton-star" + +[[station]] +name = "odtc-1" +kind = "inheco.odtc" +address = "169.254.10.40:8080" + +[[storage]] +name = "fridge-a" +kind = "fridge" +temperature_c = 4.0 +materials = ["assembly_mix", "miniprep_kit"] + +[[storage]] +name = "freezer-a" +kind = "freezer" +temperature_c = -20.0 +materials = ["BsaI", "T4_ligase", "competent_cells"] + +[transport] +between = "human" +walk_seconds = 45.0 diff --git a/viewer/README.md b/viewer/README.md new file mode 100644 index 0000000..f15a804 --- /dev/null +++ b/viewer/README.md @@ -0,0 +1,38 @@ +# Lab trace player + +Watch a simulated run in the browser: the scene a bench renders to, played +back against the trace a simulation records. The player computes nothing — +every state change on screen is an event from the trace, and seeking is +replaying events up to the chosen time. + +## Use + +Produce the two documents from a built run package: + +```sh +lab simulate path/to/wave-001 # writes sim-trace.json +lab scene path/to/wave-001 # writes scene.json (+ .gltf, .usda) +``` + +Then either drop both files onto the page, or serve them next to the +built player: + +```sh +npm install +npm run dev # open the printed URL, drop the two files +``` + +For a served deployment, `npm run build` and copy `dist/` next to the +`scene.json` and `sim-trace.json`; the player auto-loads both from its own +directory (or from `?scene=` and `?trace=` URLs). + +## What it shows + +- The bench in millimeter-exact positions: deck, carriers, labware, wells. +- Labware moving between stations on each confirmed handoff. +- Station state: doors amber while open, cyclers tinted while running. +- The attention timeline: amber spans are when an operator is needed; + everything else is walk-away time. Click to seek, 1× to 600× playback. + +The `.gltf` and `.usda` files beside `scene.json` are the same scene for +standard tools (Blender, three.js, Isaac Sim, Omniverse, Unreal). diff --git a/viewer/index.html b/viewer/index.html new file mode 100644 index 0000000..70c7e8d --- /dev/null +++ b/viewer/index.html @@ -0,0 +1,72 @@ + + + + + + Lab trace player + + + +
+
+
+
t+00:00:00
+
+
+
+
operator needed
+
+
+
+
+
+
+
+ + speed + + + + + +
+
+
+
+

Lab trace player

+

Drop a scene.json and a sim-trace.json here
+ (both at once or one after the other), produced by
+ lab scene <wave> and lab simulate <wave>.

+

+
+
+ + + diff --git a/viewer/package-lock.json b/viewer/package-lock.json new file mode 100644 index 0000000..ba5aec4 --- /dev/null +++ b/viewer/package-lock.json @@ -0,0 +1,1204 @@ +{ + "name": "lab-viewer", + "version": "0.1.2", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "lab-viewer", + "version": "0.1.2", + "dependencies": { + "three": "^0.170.0" + }, + "devDependencies": { + "@types/three": "^0.170.0", + "typescript": "^5.6.0", + "vite": "^6.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.170.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.170.0.tgz", + "integrity": "sha512-CUm2uckq+zkCY7ZbFpviRttY+6f9fvwm6YqSqPfA5K22s9w7R4VnA3rzJse8kHVvuzLcTx+CjNCs2NYe0QFAyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": "*", + "@webgpu/types": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~0.18.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webgpu/types": { + "version": "0.1.71", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.71.tgz", + "integrity": "sha512-mMy8/ODcKhab808co15eW+yN+HgXoQxRQHTiBV9Mrvl1r0ufnid7YOcI+gi4eUWSWl9ezD6TW2KXccrL8HCh2A==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/meshoptimizer": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz", + "integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/three": { + "version": "0.170.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz", + "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/viewer/package.json b/viewer/package.json new file mode 100644 index 0000000..b29f445 --- /dev/null +++ b/viewer/package.json @@ -0,0 +1,20 @@ +{ + "name": "lab-viewer", + "private": true, + "version": "0.1.2", + "description": "Trace player: watch a simulated Lab run in the browser", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview" + }, + "dependencies": { + "three": "^0.170.0" + }, + "devDependencies": { + "@types/three": "^0.170.0", + "typescript": "^5.6.0", + "vite": "^6.0.0" + } +} diff --git a/viewer/src/main.ts b/viewer/src/main.ts new file mode 100644 index 0000000..4bda77d --- /dev/null +++ b/viewer/src/main.ts @@ -0,0 +1,534 @@ +// The trace player: a scene (`lab.scene.v0`) drawn once, then driven +// entirely by a simulation trace (`lab.sim-trace.v0`). The viewer computes +// nothing — every state change it shows comes from an event, and seeking +// is replaying events from the start up to the chosen time. + +import * as THREE from "three"; +import { OrbitControls } from "three/addons/controls/OrbitControls.js"; + +// ---------- document types ---------- + +type Geometry = + | { shape: "box"; x: number; y: number; z: number } + | { shape: "cylinder"; diameter: number; height: number }; + +interface SceneNode { + id: string; + semantic: { kind: string; [key: string]: unknown }; + translation: [number, number, number]; + geometry?: Geometry; + children?: SceneNode[]; +} + +interface SceneDocument { + format: string; + name: string; + root: SceneNode; +} + +interface TimedEvent { + t: number; + event: string; + [key: string]: unknown; +} + +interface TraceDocument { + format: string; + durations: string; + events: TimedEvent[]; + summary: { + total_seconds: number; + attended_seconds: number; + walkaway_seconds: number; + nodes: number; + attention_windows: { node: string; from_seconds: number; to_seconds: number }[]; + }; +} + +// ---------- three.js scene construction ---------- + +const MATERIALS: Record = { + deck: new THREE.MeshStandardMaterial({ color: 0x8c8f94, roughness: 0.9 }), + carrier: new THREE.MeshStandardMaterial({ color: 0x54575e, roughness: 0.8 }), + labware: new THREE.MeshStandardMaterial({ color: 0xebecef, roughness: 0.5 }), + tips: new THREE.MeshStandardMaterial({ color: 0xe69933, roughness: 0.6 }), + well: new THREE.MeshStandardMaterial({ + color: 0x4d8ce6, + roughness: 0.3, + transparent: true, + opacity: 0.45, + }), + station: new THREE.MeshStandardMaterial({ color: 0x9ea3ab, roughness: 0.9 }), +}; + +function materialFor(node: SceneNode): THREE.MeshStandardMaterial { + const kind = node.semantic.kind; + if (kind === "deck") return MATERIALS.deck; + if (kind === "carrier") return MATERIALS.carrier; + if (kind === "labware" || kind === "well") { + const catalog = String(node.semantic.catalog ?? node.id); + if (catalog.includes("tip") || node.id.includes("tip")) return MATERIALS.tips; + return kind === "well" ? MATERIALS.well : MATERIALS.labware; + } + return MATERIALS.station; +} + +const byId = new Map(); +const stationMeshes = new Map(); +const stationHeights = new Map(); + +// Motion state, all cosmetic: targets come from trace events; these only +// decide how the picture gets from one true state to the next. +const heads = new Map(); +const headTargets = new Map(); +const thermalActive = new Set(); +interface Tween { + object: THREE.Object3D; + from: THREE.Vector3; + to: THREE.Vector3; + started: number; +} +let tweens: Tween[] = []; +const TWEEN_SECONDS = 0.7; + +function buildNode(node: SceneNode): THREE.Object3D { + const group = new THREE.Group(); + group.name = node.id; + group.position.set(...node.translation); + byId.set(node.id, group); + + if (node.geometry) { + let mesh: THREE.Mesh; + if (node.geometry.shape === "box") { + const { x, y, z } = node.geometry; + mesh = new THREE.Mesh(new THREE.BoxGeometry(x, y, z), materialFor(node).clone()); + // The scene convention puts a box's minimum corner at the node + // origin; three centers geometry. + mesh.position.set(x / 2, y / 2, z / 2); + } else { + const { diameter, height } = node.geometry; + mesh = new THREE.Mesh( + new THREE.CylinderGeometry(diameter / 2, diameter / 2, height, 20), + materialFor(node).clone(), + ); + // Cylinders stand on the node origin along lab Z; three's cylinder + // axis is local Y. + mesh.rotation.x = Math.PI / 2; + mesh.position.set(0, 0, height / 2); + } + group.add(mesh); + if (node.semantic.kind === "station") { + stationMeshes.set(node.id, mesh); + stationHeights.set( + node.id, + node.geometry.shape === "box" ? node.geometry.z : node.geometry.height, + ); + } + } + for (const child of node.children ?? []) { + group.add(buildNode(child)); + } + return group; +} + +// ---------- playback state ---------- + +interface LabwareHome { + parent: THREE.Object3D; + position: THREE.Vector3; +} + +const homes = new Map(); +let trace: TraceDocument | null = null; +let sceneDoc: SceneDocument | null = null; +let simTime = 0; +let playing = false; +let speed = 60; +let applied = 0; // events applied so far, for incremental playback + +function rememberHomes(): void { + homes.clear(); + for (const [id, object] of byId) { + homes.set(id, { parent: object.parent!, position: object.position.clone() }); + } +} + +function resetState(): void { + for (const [id, home] of homes) { + const object = byId.get(id)!; + if (object.parent !== home.parent) home.parent.add(object); + object.position.copy(home.position); + } + for (const mesh of stationMeshes.values()) { + (mesh.material as THREE.MeshStandardMaterial).emissive.setHex(0x000000); + } + for (const head of heads.values()) head.visible = false; + headTargets.clear(); + thermalActive.clear(); + tweens = []; + hideAttention(); + applied = 0; +} + +/** The pipetting head a liquid handler shows while its frames run: a + * carriage block with a tip below it, gliding between frame targets. */ +function ensureHead(station: string): THREE.Group { + let head = heads.get(station); + if (head) return head; + head = new THREE.Group(); + const carriage = new THREE.Mesh( + new THREE.BoxGeometry(60, 60, 110), + new THREE.MeshStandardMaterial({ color: 0x2f3239, roughness: 0.4 }), + ); + carriage.position.z = 55; + const tip = new THREE.Mesh( + new THREE.CylinderGeometry(2.5, 1.2, 70, 10), + new THREE.MeshStandardMaterial({ color: 0xd8dbe2, roughness: 0.3 }), + ); + tip.rotation.x = Math.PI / 2; + tip.position.z = -35; + head.add(carriage, tip); + head.visible = false; + (byId.get(station) ?? labRoot).add(head); + heads.set(station, head); + return head; +} + +/** Moves labware to a station: same world pose logic a handoff implies. + * Animated during playback, snapped when seeking. */ +function moveLabware(labware: string, to: string, animate: boolean): void { + const object = byId.get(labware); + const station = byId.get(to); + if (!object || !station || !object.parent) return; + // Work in the lab frame (Z-up millimeters): seat the plate on top of + // the destination's body so it reads as "on" the instrument rather + // than inside it, then map into the labware's parent frame. + const point = new THREE.Vector3(); + station.getWorldPosition(point); + labRoot.worldToLocal(point); + point.z += (stationHeights.get(to) ?? 0) + 5; + labRoot.localToWorld(point); + object.parent.worldToLocal(point); + if (animate) { + tweens = tweens.filter((tween) => tween.object !== object); + tweens.push({ + object, + from: object.position.clone(), + to: point.clone(), + started: performance.now(), + }); + } else { + object.position.copy(point); + } +} + +function applyEvent(timed: TimedEvent, animate: boolean): void { + switch (timed.event) { + case "labware-moved": + moveLabware(String(timed.labware), String(timed.to), animate); + break; + case "door-opened": + thermalActive.delete(String(timed.station)); + tintStation(String(timed.station), 0x664400); + break; + case "door-closed": + tintStation(String(timed.station), 0x000000); + break; + case "thermal-running": + thermalActive.add(String(timed.station)); + tintStation(String(timed.station), 0x551111); + break; + case "frame": { + if (typeof timed.x_mm === "number" && typeof timed.y_mm === "number") { + const station = String(timed.station); + const head = ensureHead(station); + head.visible = true; + const target = new THREE.Vector3(timed.x_mm, timed.y_mm, 260); + headTargets.set(station, target); + if (!animate) head.position.copy(target); + } + break; + } + case "node-completed": + for (const head of heads.values()) head.visible = false; + break; + } + // Caption: the latest human-meaningful line. + const caption = describe(timed); + if (caption) document.getElementById("caption")!.textContent = caption; +} + +function tintStation(station: string, hex: number): void { + const mesh = stationMeshes.get(station); + if (mesh) (mesh.material as THREE.MeshStandardMaterial).emissive.setHex(hex); +} + +function describe(timed: TimedEvent): string | null { + switch (timed.event) { + case "node-started": + return `▸ ${timed.id}`; + case "program-started": + return `${timed.station}: ${timed.title}`; + case "attention-required": + return `by hand — ${timed.prompt}`; + case "labware-moved": + return `${timed.labware}: ${timed.from} → ${timed.to}`; + case "thermal-hold": + return `holding ${timed.celsius} °C`; + default: + return null; + } +} + +function showAttention(prompt: string): void { + const panel = document.getElementById("attention")!; + panel.classList.add("active"); + document.getElementById("attention-text")!.textContent = prompt; +} + +function hideAttention(): void { + document.getElementById("attention")!.classList.remove("active"); +} + +function updateAttentionPanel(): void { + if (!trace) return; + const window = trace.summary.attention_windows.find( + (candidate) => simTime >= candidate.from_seconds && simTime < candidate.to_seconds, + ); + if (window) { + const required = trace.events.find( + (event) => event.event === "attention-required" && event.node === window.node, + ); + showAttention(String(required?.prompt ?? window.node)); + } else { + hideAttention(); + } +} + +/** Seek: replay from zero, snapping. Play: continue from `applied`, + * animating. */ +function applyUpTo(t: number, animate: boolean): void { + if (!trace) return; + if (t < simTime || applied === 0) { + resetState(); + animate = false; + } + simTime = t; + while (applied < trace.events.length && trace.events[applied].t <= t) { + applyEvent(trace.events[applied], animate); + applied += 1; + } + updateAttentionPanel(); + updateHud(); +} + +// ---------- hud ---------- + +function hms(seconds: number): string { + const total = Math.round(seconds); + const h = Math.floor(total / 3600); + const m = Math.floor((total % 3600) / 60); + const s = total % 60; + return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; +} + +function updateHud(): void { + if (!trace) return; + document.getElementById("clock")!.textContent = `t+${hms(simTime)}`; + const fraction = trace.summary.total_seconds > 0 ? simTime / trace.summary.total_seconds : 0; + (document.getElementById("cursor") as HTMLElement).style.left = + `${Math.min(100, fraction * 100)}%`; +} + +function buildTimeline(): void { + if (!trace) return; + const timeline = document.getElementById("timeline")!; + for (const window of trace.summary.attention_windows) { + const span = document.createElement("div"); + span.className = "attention-span"; + const total = trace.summary.total_seconds; + span.style.left = `${(window.from_seconds / total) * 100}%`; + span.style.width = `${Math.max(0.4, ((window.to_seconds - window.from_seconds) / total) * 100)}%`; + span.title = window.node; + timeline.appendChild(span); + } + timeline.addEventListener("pointerdown", (pointer) => { + const rect = timeline.getBoundingClientRect(); + const fraction = (pointer.clientX - rect.left) / rect.width; + applyUpTo(Math.max(0, Math.min(1, fraction)) * trace!.summary.total_seconds, false); + }); + const summary = trace.summary; + document.getElementById("summary")!.innerHTML = + `${trace!.summary.nodes} nodes, total ${hms(summary.total_seconds)}
` + + `attended ${hms(summary.attended_seconds)} in ` + + `${summary.attention_windows.length} window(s); ` + + `walk-away ${hms(summary.walkaway_seconds)}
` + + `durations: ${trace!.durations} (estimates)`; +} + +// ---------- renderer ---------- + +const app = document.getElementById("app")!; +const renderer = new THREE.WebGLRenderer({ antialias: true }); +renderer.setSize(window.innerWidth, window.innerHeight); +renderer.setPixelRatio(window.devicePixelRatio); +app.appendChild(renderer.domElement); + +const three = new THREE.Scene(); +three.background = new THREE.Color(0x14161a); +const camera = new THREE.PerspectiveCamera( + 50, + window.innerWidth / window.innerHeight, + 0.01, + 100, +); +camera.position.set(1.4, 1.9, 1.5); +const controls = new OrbitControls(camera, renderer.domElement); +controls.target.set(0.9, 1.0, -0.3); + +three.add(new THREE.AmbientLight(0xffffff, 0.6)); +const key = new THREE.DirectionalLight(0xffffff, 1.4); +key.position.set(2, 4, 3); +three.add(key); +const fill = new THREE.DirectionalLight(0xffffff, 0.5); +fill.position.set(-3, 2, -2); +three.add(fill); + +// Lab frame (Z-up millimeters) into three's Y-up meters. +const labRoot = new THREE.Group(); +labRoot.rotation.x = -Math.PI / 2; +labRoot.scale.setScalar(0.001); +three.add(labRoot); + +window.addEventListener("resize", () => { + camera.aspect = window.innerWidth / window.innerHeight; + camera.updateProjectionMatrix(); + renderer.setSize(window.innerWidth, window.innerHeight); +}); + +let lastFrame = performance.now(); +function frame(now: number): void { + const dt = (now - lastFrame) / 1000; + lastFrame = now; + if (playing && trace) { + const next = Math.min(simTime + dt * speed, trace.summary.total_seconds); + applyUpTo(next, true); + if (next >= trace.summary.total_seconds) setPlaying(false); + } + + // Cosmetic motion between true states: glide the pipetting heads to + // their latest frame targets, ease labware tweens, pulse running + // cyclers. + const ease = 1 - Math.exp(-dt * 10); + for (const [station, target] of headTargets) { + const head = heads.get(station); + if (head?.visible) head.position.lerp(target, ease); + } + tweens = tweens.filter((tween) => { + const fraction = Math.min(1, (now - tween.started) / (TWEEN_SECONDS * 1000)); + const smooth = fraction * fraction * (3 - 2 * fraction); + tween.object.position.lerpVectors(tween.from, tween.to, smooth); + // Carry the plate in an arc rather than through the bench. + tween.object.position.z += Math.sin(smooth * Math.PI) * 120; + return fraction < 1; + }); + for (const station of thermalActive) { + const mesh = stationMeshes.get(station); + if (mesh) { + (mesh.material as THREE.MeshStandardMaterial).emissiveIntensity = + 1.0 + 0.8 * Math.sin(now / 250); + } + } + + controls.update(); + renderer.render(three, camera); + requestAnimationFrame(frame); +} +requestAnimationFrame(frame); + +// ---------- transport controls ---------- + +function setPlaying(on: boolean): void { + playing = on; + const button = document.getElementById("play")!; + button.textContent = on ? "pause" : "play"; + button.classList.toggle("on", on); +} + +document.getElementById("play")!.addEventListener("click", () => { + if (trace && simTime >= trace.summary.total_seconds) applyUpTo(0, false); + setPlaying(!playing); +}); +for (const button of document.querySelectorAll("[data-speed]")) { + button.addEventListener("click", () => { + speed = Number(button.dataset.speed); + for (const other of document.querySelectorAll("[data-speed]")) { + other.classList.toggle("on", other === button); + } + }); +} + +// ---------- loading ---------- + +function tryStart(): void { + if (!sceneDoc || !trace) return; + labRoot.clear(); + byId.clear(); + stationMeshes.clear(); + labRoot.add(buildNode(sceneDoc.root)); + rememberHomes(); + buildTimeline(); + applyUpTo(0, false); + document.getElementById("drop")!.classList.add("hidden"); + document.getElementById("status")!.textContent = + `${sceneDoc.name} · ${trace.events.length} events`; + setPlaying(true); +} + +function accept(name: string, parsed: unknown): void { + const document_ = parsed as { format?: string }; + const status = document.getElementById("drop-status")!; + if (document_.format === "lab.scene.v0") { + sceneDoc = parsed as SceneDocument; + status.textContent = trace ? "" : "scene loaded — now the sim-trace.json"; + } else if (document_.format === "lab.sim-trace.v0") { + trace = parsed as TraceDocument; + status.textContent = sceneDoc ? "" : "trace loaded — now the scene.json"; + } else { + status.textContent = `${name}: not a lab.scene.v0 or lab.sim-trace.v0 document`; + return; + } + tryStart(); +} + +document.addEventListener("dragover", (event) => event.preventDefault()); +document.addEventListener("drop", async (event) => { + event.preventDefault(); + for (const file of event.dataTransfer?.files ?? []) { + try { + accept(file.name, JSON.parse(await file.text())); + } catch { + document.getElementById("drop-status")!.textContent = `${file.name}: not JSON`; + } + } +}); + +// Served alongside its data (or with ?scene=&trace= query params), the +// player loads without any drop. +async function autoload(): Promise { + const parameters = new URLSearchParams(window.location.search); + const sceneUrl = parameters.get("scene") ?? "scene.json"; + const traceUrl = parameters.get("trace") ?? "sim-trace.json"; + for (const [name, url] of [ + ["scene", sceneUrl], + ["trace", traceUrl], + ] as const) { + try { + const response = await fetch(url); + if (response.ok) accept(name, await response.json()); + } catch { + // Drag and drop remains the path. + } + } +} +void autoload(); diff --git a/viewer/tsconfig.json b/viewer/tsconfig.json new file mode 100644 index 0000000..1967737 --- /dev/null +++ b/viewer/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "isolatedModules": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": ["src"] +} From 6f457f38a3635c65fd4b85901740770666828bd7 Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Tue, 11 Aug 2026 21:40:59 -0600 Subject: [PATCH 02/17] Move trace and facility schemas into lab-runfmt The event vocabulary, the lab.sim-trace.v0 document, its summary derivation, and the facility description are documents, not runtime behavior, so they live with the other formats. lab-runtime re-exports them unchanged and keeps only the ports: EventSink, the sinks, and the clock-coupled TraceSink. lab-scene can now read traces and facilities without a lab-runtime edge. --- Cargo.lock | 2 +- crates/lab-runfmt/Cargo.toml | 1 + .../src/facility.rs | 13 +- crates/lab-runfmt/src/lib.rs | 8 + crates/lab-runfmt/src/trace.rs | 204 ++++++++++++++++++ crates/lab-runtime/Cargo.toml | 1 - crates/lab-runtime/src/events.rs | 100 +-------- crates/lab-runtime/src/lib.rs | 2 +- crates/lab-runtime/src/trace.rs | 114 +--------- 9 files changed, 231 insertions(+), 214 deletions(-) rename crates/{lab-runtime => lab-runfmt}/src/facility.rs (97%) create mode 100644 crates/lab-runfmt/src/trace.rs diff --git a/Cargo.lock b/Cargo.lock index 1bfb49e..6d141e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1727,6 +1727,7 @@ dependencies = [ "serde_json", "tempfile", "thiserror", + "toml", ] [[package]] @@ -1741,7 +1742,6 @@ dependencies = [ "serde_json", "tempfile", "thiserror", - "toml", ] [[package]] diff --git a/crates/lab-runfmt/Cargo.toml b/crates/lab-runfmt/Cargo.toml index 7954fd7..9650f7f 100644 --- a/crates/lab-runfmt/Cargo.toml +++ b/crates/lab-runfmt/Cargo.toml @@ -12,6 +12,7 @@ lab-instruments = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } +toml = { workspace = true } [dev-dependencies] tempfile = "3" diff --git a/crates/lab-runtime/src/facility.rs b/crates/lab-runfmt/src/facility.rs similarity index 97% rename from crates/lab-runtime/src/facility.rs rename to crates/lab-runfmt/src/facility.rs index 2b30179..c92b2a6 100644 --- a/crates/lab-runtime/src/facility.rs +++ b/crates/lab-runfmt/src/facility.rs @@ -201,10 +201,7 @@ impl Facility { /// Checks that every station a plan needs exists here, by name and /// kind. A near-miss names the kind mismatch instead of a bare absence. - pub fn check_stations( - &self, - required: &[lab_runfmt::WorkcellStation], - ) -> Result<(), FacilityError> { + pub fn check_stations(&self, required: &[crate::WorkcellStation]) -> Result<(), FacilityError> { for needed in required { match self.station(&needed.name) { Some(station) if station.kind == needed.kind => {} @@ -281,12 +278,12 @@ walk_seconds = 45.0 assert_eq!(facility.transport.walk_seconds, 45.0); let plan_stations = vec![ - lab_runfmt::WorkcellStation { + crate::WorkcellStation { name: "star-1".to_string(), kind: "hamilton.star".to_string(), program_dir: "stations/star-1".to_string(), }, - lab_runfmt::WorkcellStation { + crate::WorkcellStation { name: "odtc-1".to_string(), kind: "inheco.odtc".to_string(), program_dir: "stations/odtc-1".to_string(), @@ -294,7 +291,7 @@ walk_seconds = 45.0 ]; facility.check_stations(&plan_stations).unwrap(); - let elsewhere = vec![lab_runfmt::WorkcellStation { + let elsewhere = vec![crate::WorkcellStation { name: "reader-1".to_string(), kind: "byonoy.absorbance96".to_string(), program_dir: "stations/reader-1".to_string(), @@ -309,7 +306,7 @@ walk_seconds = 45.0 #[test] fn a_kind_mismatch_is_named_rather_than_reported_as_absence() { let facility = Facility::parse("main-bench.toml", MAIN_BENCH).unwrap(); - let mismatched = vec![lab_runfmt::WorkcellStation { + let mismatched = vec![crate::WorkcellStation { name: "odtc-1".to_string(), kind: "byonoy.absorbance96".to_string(), program_dir: "stations/odtc-1".to_string(), diff --git a/crates/lab-runfmt/src/lib.rs b/crates/lab-runfmt/src/lib.rs index 1948a18..29fd880 100644 --- a/crates/lab-runfmt/src/lib.rs +++ b/crates/lab-runfmt/src/lib.rs @@ -10,6 +10,14 @@ //! loaders in this crate, so a wrong or missing format string fails the same //! way everywhere. +pub mod facility; +mod trace; + +pub use trace::{ + AttentionWindow, ProgramExtent, RunEvent, SIM_TRACE_FORMAT, SimSummary, SimTraceDocument, + StationSummary, TimedEvent, summarize, +}; + use std::path::Path; use serde::{Deserialize, Serialize}; diff --git a/crates/lab-runfmt/src/trace.rs b/crates/lab-runfmt/src/trace.rs new file mode 100644 index 0000000..37ed62e --- /dev/null +++ b/crates/lab-runfmt/src/trace.rs @@ -0,0 +1,204 @@ +//! `lab.sim-trace.v0`: the record a simulation leaves behind. +//! +//! The trace is the contract for all visualization. Every event carries the +//! virtual time it happened at; a viewer plays events and computes nothing. +//! Like the run formats, a change to what an event means is a new format +//! version, not an edit. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +/// The format string every `lab.sim-trace.v0` document declares. +pub const SIM_TRACE_FORMAT: &str = "lab.sim-trace.v0"; + +/// One observable moment in a run walk. Events carry facts, not phrasing; +/// each consumer decides how to present them. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "event", rename_all = "kebab-case")] +pub enum RunEvent { + /// The walk is about to start `pending` nodes, skipping `completed`. + Planned { + pending: usize, + completed: usize, + }, + Connecting { + station: String, + detail: String, + }, + Connected { + station: String, + }, + NodeStarted { + id: String, + }, + NodeSkipped { + id: String, + }, + NodeCompleted { + id: String, + }, + /// A station program began: a STAR frame sequence or a thermal profile. + ProgramStarted { + station: String, + title: String, + #[serde(flatten)] + extent: ProgramExtent, + }, + /// One STAR frame is about to execute. When the frame carries deck + /// coordinates, the first channel's target rides along so a viewer + /// can move a pipetting head to where the work happens. + Frame { + station: String, + index: usize, + description: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + x_mm: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + y_mm: Option, + }, + /// The thermal profile is running to completion on its station. + ThermalRunning { + station: String, + }, + ThermalWarning { + station: String, + warning: String, + }, + /// The block holds a temperature until retrieval. + ThermalHold { + station: String, + celsius: f64, + }, + DoorOpened { + station: String, + }, + DoorClosed { + station: String, + }, + /// The operator is needed, starting now. + AttentionRequired { + node: String, + prompt: String, + }, + /// The operator's step is done; the walk is unattended again. + AttentionReleased { + node: String, + }, + /// Labware physically moved between stations. + LabwareMoved { + labware: String, + from: String, + to: String, + }, +} + +/// How large a station program is, in the unit the station thinks in. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ProgramExtent { + Frames { + frames: usize, + }, + Plateaus { + plateaus: usize, + #[serde(default, skip_serializing_if = "Option::is_none")] + final_hold_celsius: Option, + }, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct SimTraceDocument { + /// Always [`SIM_TRACE_FORMAT`]; readers reject any other value. + pub format: String, + /// What was simulated: the plan or manifest path, relative to the + /// package directory. + pub plan: String, + /// The duration model's name; timings are estimates under that model. + pub durations: String, + pub events: Vec, + pub summary: SimSummary, +} + +/// One event at a virtual time, in seconds from the simulation's start. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TimedEvent { + pub t: f64, + #[serde(flatten)] + pub event: RunEvent, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct SimSummary { + pub total_seconds: f64, + /// Seconds an operator must be present. + pub attended_seconds: f64, + /// Seconds the run proceeds without anyone watching. + pub walkaway_seconds: f64, + pub nodes: usize, + pub stations: BTreeMap, + pub attention_windows: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct StationSummary { + pub busy_seconds: f64, +} + +/// One interval an operator is needed for, and why. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct AttentionWindow { + pub node: String, + pub from_seconds: f64, + pub to_seconds: f64, +} + +/// Derives the summary a trace carries: totals, attended intervals, and +/// per-station busy time, all from the recorded events. +pub fn summarize(events: &[TimedEvent], total_seconds: f64) -> SimSummary { + let mut attention_windows = Vec::new(); + let mut open_attention: Option<(String, f64)> = None; + let mut stations: BTreeMap = BTreeMap::new(); + let mut open_program: Option<(String, f64)> = None; + let mut nodes = 0usize; + + for timed in events { + match &timed.event { + RunEvent::AttentionRequired { node, .. } => { + open_attention = Some((node.clone(), timed.t)); + } + RunEvent::AttentionReleased { .. } => { + if let Some((node, from_seconds)) = open_attention.take() { + attention_windows.push(AttentionWindow { + node, + from_seconds, + to_seconds: timed.t, + }); + } + } + RunEvent::ProgramStarted { station, .. } => { + open_program = Some((station.clone(), timed.t)); + } + RunEvent::NodeCompleted { .. } => { + nodes += 1; + if let Some((station, from)) = open_program.take() { + stations.entry(station).or_default().busy_seconds += timed.t - from; + } + } + _ => {} + } + } + + let attended_seconds: f64 = attention_windows + .iter() + .map(|window| window.to_seconds - window.from_seconds) + .sum(); + SimSummary { + total_seconds, + attended_seconds, + walkaway_seconds: (total_seconds - attended_seconds).max(0.0), + nodes, + stations, + attention_windows, + } +} diff --git a/crates/lab-runtime/Cargo.toml b/crates/lab-runtime/Cargo.toml index 1d7c681..4dc9832 100644 --- a/crates/lab-runtime/Cargo.toml +++ b/crates/lab-runtime/Cargo.toml @@ -20,7 +20,6 @@ lab-runfmt = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } -toml = { workspace = true } [dev-dependencies] tempfile = "3" diff --git a/crates/lab-runtime/src/events.rs b/crates/lab-runtime/src/events.rs index 8dc5dc6..c23fb51 100644 --- a/crates/lab-runtime/src/events.rs +++ b/crates/lab-runtime/src/events.rs @@ -1,103 +1,11 @@ //! The event port: everything a run walk has to say goes through one sink. //! The live runner's sink prints human narration; the simulator's sink //! stamps virtual time on each event and accumulates the trace. +//! +//! The event vocabulary itself is trace schema and lives in `lab-runfmt`; +//! this module owns the runtime ports that carry it. -use serde::{Deserialize, Serialize}; - -/// One observable moment in a run walk. Events carry facts, not phrasing; -/// each sink decides how to present them. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(tag = "event", rename_all = "kebab-case")] -pub enum RunEvent { - /// The walk is about to start `pending` nodes, skipping `completed`. - Planned { - pending: usize, - completed: usize, - }, - Connecting { - station: String, - detail: String, - }, - Connected { - station: String, - }, - NodeStarted { - id: String, - }, - NodeSkipped { - id: String, - }, - NodeCompleted { - id: String, - }, - /// A station program began: a STAR frame sequence or a thermal profile. - ProgramStarted { - station: String, - title: String, - #[serde(flatten)] - extent: ProgramExtent, - }, - /// One STAR frame is about to execute. When the frame carries deck - /// coordinates, the first channel's target rides along so a viewer - /// can move a pipetting head to where the work happens. - Frame { - station: String, - index: usize, - description: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - x_mm: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - y_mm: Option, - }, - /// The thermal profile is running to completion on its station. - ThermalRunning { - station: String, - }, - ThermalWarning { - station: String, - warning: String, - }, - /// The block holds a temperature until retrieval. - ThermalHold { - station: String, - celsius: f64, - }, - DoorOpened { - station: String, - }, - DoorClosed { - station: String, - }, - /// The operator is needed, starting now. - AttentionRequired { - node: String, - prompt: String, - }, - /// The operator's step is done; the walk is unattended again. - AttentionReleased { - node: String, - }, - /// Labware physically moved between stations. - LabwareMoved { - labware: String, - from: String, - to: String, - }, -} - -/// How large a station program is, in the unit the station thinks in. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum ProgramExtent { - Frames { - frames: usize, - }, - Plateaus { - plateaus: usize, - #[serde(default, skip_serializing_if = "Option::is_none")] - final_hold_celsius: Option, - }, -} +pub use lab_runfmt::{ProgramExtent, RunEvent}; pub trait EventSink { fn emit(&mut self, event: RunEvent); diff --git a/crates/lab-runtime/src/lib.rs b/crates/lab-runtime/src/lib.rs index cad9dd7..386050d 100644 --- a/crates/lab-runtime/src/lib.rs +++ b/crates/lab-runtime/src/lib.rs @@ -18,7 +18,7 @@ pub mod clock; pub mod durations; pub mod events; -pub mod facility; +pub use lab_runfmt::facility; pub mod ledger; pub mod operator; pub mod simulate; diff --git a/crates/lab-runtime/src/trace.rs b/crates/lab-runtime/src/trace.rs index a1f7cb3..f41d646 100644 --- a/crates/lab-runtime/src/trace.rs +++ b/crates/lab-runtime/src/trace.rs @@ -1,68 +1,18 @@ -//! `lab.sim-trace.v0`: the record a simulation leaves behind. -//! -//! The trace is the contract for all visualization. Every event carries the -//! virtual time it happened at; a viewer plays events and computes nothing. -//! Like the run formats, a change to what an event means is a new format -//! version, not an edit. +//! The runtime half of tracing: the sink that stamps virtual time on each +//! event. The trace document schema itself (`lab.sim-trace.v0`) lives in +//! `lab-runfmt` and is re-exported here. use std::cell::RefCell; -use std::collections::BTreeMap; use std::rc::Rc; -use serde::{Deserialize, Serialize}; +pub use lab_runfmt::{ + AttentionWindow, SIM_TRACE_FORMAT, SimSummary, SimTraceDocument, StationSummary, TimedEvent, + summarize, +}; use crate::clock::VirtualClock; use crate::events::{EventSink, RunEvent}; -/// The format string every `lab.sim-trace.v0` document declares. -pub const SIM_TRACE_FORMAT: &str = "lab.sim-trace.v0"; - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct SimTraceDocument { - /// Always [`SIM_TRACE_FORMAT`]; readers reject any other value. - pub format: String, - /// What was simulated: the plan or manifest path, relative to the - /// package directory. - pub plan: String, - /// The duration model's name; timings are estimates under that model. - pub durations: String, - pub events: Vec, - pub summary: SimSummary, -} - -/// One event at a virtual time, in seconds from the simulation's start. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct TimedEvent { - pub t: f64, - #[serde(flatten)] - pub event: RunEvent, -} - -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -pub struct SimSummary { - pub total_seconds: f64, - /// Seconds an operator must be present. - pub attended_seconds: f64, - /// Seconds the run proceeds without anyone watching. - pub walkaway_seconds: f64, - pub nodes: usize, - pub stations: BTreeMap, - pub attention_windows: Vec, -} - -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -pub struct StationSummary { - pub busy_seconds: f64, -} - -/// One interval an operator is needed for, and why. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct AttentionWindow { - pub node: String, - pub from_seconds: f64, - pub to_seconds: f64, -} - /// The sink a simulation emits through: stamps every event with the shared /// clock's current time. pub struct TraceSink { @@ -87,53 +37,3 @@ impl EventSink for TraceSink { }); } } - -/// Derives the summary a trace carries: totals, attended intervals, and -/// per-station busy time, all from the recorded events. -pub fn summarize(events: &[TimedEvent], total_seconds: f64) -> SimSummary { - let mut attention_windows = Vec::new(); - let mut open_attention: Option<(String, f64)> = None; - let mut stations: BTreeMap = BTreeMap::new(); - let mut open_program: Option<(String, f64)> = None; - let mut nodes = 0usize; - - for timed in events { - match &timed.event { - RunEvent::AttentionRequired { node, .. } => { - open_attention = Some((node.clone(), timed.t)); - } - RunEvent::AttentionReleased { .. } => { - if let Some((node, from_seconds)) = open_attention.take() { - attention_windows.push(AttentionWindow { - node, - from_seconds, - to_seconds: timed.t, - }); - } - } - RunEvent::ProgramStarted { station, .. } => { - open_program = Some((station.clone(), timed.t)); - } - RunEvent::NodeCompleted { .. } => { - nodes += 1; - if let Some((station, from)) = open_program.take() { - stations.entry(station).or_default().busy_seconds += timed.t - from; - } - } - _ => {} - } - } - - let attended_seconds: f64 = attention_windows - .iter() - .map(|window| window.to_seconds - window.from_seconds) - .sum(); - SimSummary { - total_seconds, - attended_seconds, - walkaway_seconds: (total_seconds - attended_seconds).max(0.0), - nodes, - stations, - attention_windows, - } -} From 33223a015301de7ea8ae7bdd62a13788c2e040cc Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Tue, 11 Aug 2026 21:54:13 -0600 Subject: [PATCH 03/17] Add the mesh asset tier and node rotation to scenes A scene node's geometry can now be a real asset: AssetCatalog resolves station kinds, labware ids, carrier ids, and 'room' against a facility's assets directory, falling back to the dimensioned box that every consumer can draw. bundle_assets copies referenced files beside the scene and relativizes their paths, so a scene bundle is self-contained. The USD export composes assets with prim references; glTF keeps the fallback box and records the asset path in extras, since core glTF cannot reference external meshes. Nodes gain rotation_z_deg for placed stations. --- Cargo.lock | 1 + crates/lab-cli/src/scene.rs | 4 +- crates/lab-scene/Cargo.toml | 3 + crates/lab-scene/src/assets.rs | 159 +++++++++++++++++++++++++++++++ crates/lab-scene/src/gltf.rs | 20 +++- crates/lab-scene/src/lib.rs | 1 + crates/lab-scene/src/scene.rs | 24 +++++ crates/lab-scene/src/star.rs | 29 +++--- crates/lab-scene/src/usda.rs | 97 +++++++++++++------ crates/lab-scene/src/workcell.rs | 73 ++++++++------ 10 files changed, 333 insertions(+), 78 deletions(-) create mode 100644 crates/lab-scene/src/assets.rs diff --git a/Cargo.lock b/Cargo.lock index 6d141e7..d66014f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1753,6 +1753,7 @@ dependencies = [ "lab-runfmt", "serde", "serde_json", + "tempfile", "thiserror", ] diff --git a/crates/lab-cli/src/scene.rs b/crates/lab-cli/src/scene.rs index 6f0299b..cc5aca8 100644 --- a/crates/lab-cli/src/scene.rs +++ b/crates/lab-cli/src/scene.rs @@ -73,7 +73,7 @@ fn build_scene(directory: &Path) -> Result { .file_name() .map(|name| name.to_string_lossy().into_owned()) .unwrap_or_else(|| "workcell".to_string()); - Ok(workcell_scene(&name, stations)?) + Ok(workcell_scene(&name, stations, None)?) } else { let manifest = directory.join("automation_manifest.json"); if !manifest.is_file() { @@ -84,7 +84,7 @@ fn build_scene(directory: &Path) -> Result { } let profile = deck_from_manifest(&manifest)?; let name = profile.target.name.clone(); - Ok(star_bench_scene(&name, &profile)?) + Ok(star_bench_scene(&name, &profile, None)?) } } diff --git a/crates/lab-scene/Cargo.toml b/crates/lab-scene/Cargo.toml index 7a2aeac..bcee8fa 100644 --- a/crates/lab-scene/Cargo.toml +++ b/crates/lab-scene/Cargo.toml @@ -15,5 +15,8 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } +[dev-dependencies] +tempfile = "3" + [lints] workspace = true diff --git a/crates/lab-scene/src/assets.rs b/crates/lab-scene/src/assets.rs new file mode 100644 index 0000000..287cb76 --- /dev/null +++ b/crates/lab-scene/src/assets.rs @@ -0,0 +1,159 @@ +//! The asset registry: how a scene node gets a real mesh. +//! +//! Resolution is a three-tier fallback that never blocks a render: a file +//! in the facility's assets directory wins, the procedural primitive is +//! next, and a labeled dimensioned box is the floor every consumer can +//! draw. Keys are the identities the scene already speaks: station kind +//! strings (`hamilton.star`), labware catalog ids (`pcr_plate_96`), +//! carrier catalog ids, and `room`. +//! +//! Assets are authored in millimeters (`metersPerUnit = 0.001` in USD +//! layers), origin at the node anchor, +X right, +Y back, Z up. See +//! `docs/integrations/photoreal-assets.md` for the preparation recipe. + +use std::path::{Path, PathBuf}; + +use crate::scene::{Geometry, Scene, SceneNode}; + +/// Extensions each consumer family can load, in preference order. +const GLTF_EXTENSIONS: [&str; 2] = ["glb", "gltf"]; +const USD_EXTENSIONS: [&str; 3] = ["usd", "usdc", "usda"]; + +/// A facility's assets directory, queried by key. +pub struct AssetCatalog { + directory: PathBuf, +} + +impl AssetCatalog { + pub fn new(directory: impl Into) -> Self { + Self { + directory: directory.into(), + } + } + + fn find(&self, key: &str, extensions: &[&str]) -> Option { + extensions.iter().find_map(|extension| { + let path = self.directory.join(format!("{key}.{extension}")); + path.is_file().then(|| path.display().to_string()) + }) + } + + /// The geometry for a key: a mesh when any asset file exists, the + /// fallback box otherwise. Recorded paths point at the source files; + /// [`bundle_assets`] rewrites them relative to a scene bundle. + pub fn resolve(&self, key: &str, fallback: [f64; 3]) -> Geometry { + let gltf = self.find(key, &GLTF_EXTENSIONS); + let usd = self.find(key, &USD_EXTENSIONS); + if gltf.is_some() || usd.is_some() { + Geometry::Mesh { + gltf, + usd, + fallback, + } + } else { + Geometry::Box { + x: fallback[0], + y: fallback[1], + z: fallback[2], + } + } + } +} + +/// Copies every referenced asset into `/assets/` and rewrites the +/// scene's paths to be relative to the scene file, so a scene bundle is +/// self-contained for the web server and for USD tools alike. Returns the +/// copied file names. +pub fn bundle_assets(scene: &mut Scene, out_dir: &Path) -> std::io::Result> { + let assets_dir = out_dir.join("assets"); + let mut copied = Vec::new(); + bundle_node(&mut scene.root, &assets_dir, &mut copied)?; + Ok(copied) +} + +fn bundle_node( + node: &mut SceneNode, + assets_dir: &Path, + copied: &mut Vec, +) -> std::io::Result<()> { + if let Some(Geometry::Mesh { gltf, usd, .. }) = &mut node.geometry { + for slot in [gltf, usd] { + if let Some(source) = slot.as_ref() { + let source_path = PathBuf::from(source); + let Some(file_name) = source_path.file_name().map(|name| name.to_owned()) else { + continue; + }; + std::fs::create_dir_all(assets_dir)?; + std::fs::copy(&source_path, assets_dir.join(&file_name))?; + let file_name = file_name.to_string_lossy().into_owned(); + *slot = Some(format!("assets/{file_name}")); + if !copied.contains(&file_name) { + copied.push(file_name); + } + } + } + } + for child in &mut node.children { + bundle_node(child, assets_dir, copied)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::scene::{SCENE_FORMAT, Semantic}; + + #[test] + fn resolution_prefers_assets_and_falls_back_to_the_box() { + let directory = tempfile::tempdir().unwrap(); + std::fs::write(directory.path().join("inheco.odtc.glb"), b"stub").unwrap(); + std::fs::write(directory.path().join("inheco.odtc.usda"), b"#usda 1.0").unwrap(); + let catalog = AssetCatalog::new(directory.path()); + + let resolved = catalog.resolve("inheco.odtc", [200.0, 320.0, 260.0]); + let Geometry::Mesh { + gltf, + usd, + fallback, + } = resolved + else { + panic!("an existing asset resolves to a mesh"); + }; + assert!(gltf.unwrap().ends_with("inheco.odtc.glb")); + assert!(usd.unwrap().ends_with("inheco.odtc.usda")); + assert_eq!(fallback, [200.0, 320.0, 260.0]); + + assert_eq!( + catalog.resolve("hamilton.star", [1.0, 2.0, 3.0]), + Geometry::Box { + x: 1.0, + y: 2.0, + z: 3.0 + }, + "a missing asset is the dimensioned box, never an error" + ); + } + + #[test] + fn bundling_copies_assets_beside_the_scene_and_relativizes_paths() { + let source = tempfile::tempdir().unwrap(); + std::fs::write(source.path().join("room.glb"), b"stub").unwrap(); + let catalog = AssetCatalog::new(source.path()); + + let mut scene = Scene { + format: SCENE_FORMAT.to_string(), + name: "test".to_string(), + root: SceneNode::new("room", Semantic::Room, [0.0, 0.0, 0.0]) + .with_geometry(catalog.resolve("room", [1.0, 1.0, 1.0])), + }; + let out = tempfile::tempdir().unwrap(); + let copied = bundle_assets(&mut scene, out.path()).unwrap(); + assert_eq!(copied, ["room.glb"]); + assert!(out.path().join("assets/room.glb").is_file()); + let Some(Geometry::Mesh { gltf, .. }) = &scene.root.geometry else { + panic!("the mesh survives bundling"); + }; + assert_eq!(gltf.as_deref(), Some("assets/room.glb")); + } +} diff --git a/crates/lab-scene/src/gltf.rs b/crates/lab-scene/src/gltf.rs index c29539d..aff20f2 100644 --- a/crates/lab-scene/src/gltf.rs +++ b/crates/lab-scene/src/gltf.rs @@ -61,18 +61,30 @@ fn emit_node(node: &SceneNode, nodes: &mut Vec) -> usize { "name": node.id, "translation": [node.translation[0], node.translation[1], node.translation[2]], })); + if node.rotation_z_deg != 0.0 { + let half = (node.rotation_z_deg.to_radians()) / 2.0; + nodes[index]["rotation"] = json!([0.0, 0.0, half.sin(), half.cos()]); + } let mut children: Vec = Vec::new(); if let Some(geometry) = &node.geometry { let geometry_index = nodes.len(); - let (mesh, scale) = match geometry { - Geometry::Box { x, y, z } => (0, [*x, *y, *z]), - Geometry::Cylinder { diameter, height } => (1, [*diameter, *diameter, *height]), + // Core glTF cannot reference external meshes, so a Mesh node ships + // its fallback box and records the asset path in extras for + // viewers that can load it themselves. + let (mesh, scale, asset_gltf) = match geometry { + Geometry::Box { x, y, z } => (0, [*x, *y, *z], None), + Geometry::Cylinder { diameter, height } => (1, [*diameter, *diameter, *height], None), + Geometry::Mesh { gltf, fallback, .. } => (0, *fallback, gltf.clone()), }; + let mut extras = json!({ "material": material_index(&node.semantic, &node.id) }); + if let Some(asset) = asset_gltf { + extras["asset_gltf"] = json!(asset); + } nodes.push(json!({ "name": format!("{}#geometry", node.id), "mesh": mesh, "scale": scale, - "extras": { "material": material_index(&node.semantic, &node.id) }, + "extras": extras, })); children.push(geometry_index); } diff --git a/crates/lab-scene/src/lib.rs b/crates/lab-scene/src/lib.rs index 34ac743..dc2ea39 100644 --- a/crates/lab-scene/src/lib.rs +++ b/crates/lab-scene/src/lib.rs @@ -12,6 +12,7 @@ //! dimensions from a side table, because the planning catalog carries //! anchor points, not bounding boxes. +pub mod assets; pub mod dims; pub mod gltf; pub mod scene; diff --git a/crates/lab-scene/src/scene.rs b/crates/lab-scene/src/scene.rs index 143b643..59e3491 100644 --- a/crates/lab-scene/src/scene.rs +++ b/crates/lab-scene/src/scene.rs @@ -39,18 +39,27 @@ pub struct SceneNode { pub semantic: Semantic, /// Millimeters, relative to the parent node. pub translation: [f64; 3], + /// Degrees counterclockwise about the node's Z axis, applied after + /// translation. Zero for everything except placed stations. + #[serde(default, skip_serializing_if = "rotation_is_zero")] + pub rotation_z_deg: f64, #[serde(default, skip_serializing_if = "Option::is_none")] pub geometry: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub children: Vec, } +fn rotation_is_zero(rotation: &f64) -> bool { + *rotation == 0.0 +} + impl SceneNode { pub fn new(id: impl Into, semantic: Semantic, translation: [f64; 3]) -> Self { Self { id: id.into(), semantic, translation, + rotation_z_deg: 0.0, geometry: None, children: Vec::new(), } @@ -62,6 +71,11 @@ impl SceneNode { } /// Depth-first traversal over the node and everything under it. + /// + /// Origins accumulate translations only: a node under a rotated + /// ancestor reports where it would sit unrotated. Renderers compose + /// full transforms natively; use this for counting and for scenes + /// whose rotated nodes have no children that need exact origins. pub fn walk(&self, visit: &mut dyn FnMut(&SceneNode, [f64; 3])) { fn inner(node: &SceneNode, origin: [f64; 3], visit: &mut dyn FnMut(&SceneNode, [f64; 3])) { let here = [ @@ -106,4 +120,14 @@ pub enum Geometry { Box { x: f64, y: f64, z: f64 }, /// Standing on the node origin, centered on it. Cylinder { diameter: f64, height: f64 }, + /// A real asset, resolved from the facility's assets directory. Paths + /// are relative to the scene file once bundled. The extents remain + /// the honest fallback for any consumer that cannot load the mesh. + Mesh { + #[serde(default, skip_serializing_if = "Option::is_none")] + gltf: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + usd: Option, + fallback: [f64; 3], + }, } diff --git a/crates/lab-scene/src/star.rs b/crates/lab-scene/src/star.rs index 0dc27b9..96ae90e 100644 --- a/crates/lab-scene/src/star.rs +++ b/crates/lab-scene/src/star.rs @@ -109,7 +109,10 @@ fn placements(profile: &StarTargetProfile) -> Vec { /// Builds the deck node for one STAR bench: deck plate, carriers at their /// rails, sites, labware with every well as a child cylinder. -pub fn star_deck_scene(profile: &StarTargetProfile) -> Result { +pub fn star_deck_scene( + profile: &StarTargetProfile, + assets: Option<&crate::assets::AssetCatalog>, +) -> Result { let rails = f64::from(profile.machine.variant.rails()); let mut deck = SceneNode::new( "deck", @@ -154,11 +157,11 @@ pub fn star_deck_scene(profile: &StarTargetProfile) -> Result Result { - // A USD Cube spans [-size/2, size/2]; scale a unit cube and - // seat its minimum corner on the node origin. - indent(text, depth + 1); - let _ = writeln!(text, "def Cube \"geometry\""); - indent(text, depth + 1); - let _ = writeln!(text, "{{"); - indent(text, depth + 2); - let _ = writeln!(text, "double size = 1"); - indent(text, depth + 2); - let _ = writeln!( - text, - "double3 xformOp:translate = ({}, {}, {})", - x / 2.0, - y / 2.0, - z / 2.0 - ); - indent(text, depth + 2); - let _ = writeln!(text, "float3 xformOp:scale = ({x}, {y}, {z})"); - indent(text, depth + 2); - let _ = writeln!( - text, - "uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:scale\"]" - ); - indent(text, depth + 1); - let _ = writeln!(text, "}}"); + emit_fallback_box(&[*x, *y, *z], depth, text); } Geometry::Cylinder { diameter, height } => { indent(text, depth + 1); @@ -122,6 +137,27 @@ fn emit_node(node: &SceneNode, depth: usize, text: &mut String) { indent(text, depth + 1); let _ = writeln!(text, "}}"); } + Geometry::Mesh { usd, fallback, .. } => match usd { + // The asset layer carries its own geometry and materials; + // referencing it composes it under this prim. + Some(path) => { + indent(text, depth + 1); + let _ = writeln!(text, "def Xform \"geometry\" ("); + indent(text, depth + 1); + let _ = writeln!(text, " prepend references = @{path}@"); + indent(text, depth + 1); + let _ = writeln!(text, ")"); + indent(text, depth + 1); + let _ = writeln!(text, "{{"); + indent(text, depth + 1); + let _ = writeln!(text, "}}"); + } + // No USD flavor of this asset: the fallback box, exactly + // as an un-assetted node renders. + None => { + emit_fallback_box(fallback, depth, text); + } + }, } } @@ -146,6 +182,7 @@ mod tests { id: "room".to_string(), semantic: Semantic::Room, translation: [0.0, 0.0, 0.0], + rotation_z_deg: 0.0, geometry: None, children: vec![ SceneNode::new( diff --git a/crates/lab-scene/src/workcell.rs b/crates/lab-scene/src/workcell.rs index 5f43a33..96002d1 100644 --- a/crates/lab-scene/src/workcell.rs +++ b/crates/lab-scene/src/workcell.rs @@ -1,10 +1,11 @@ //! The workcell room scene: stations in a row, each with what geometry it //! has. The liquid handler nests its full deck; instruments without a -//! geometry catalog render as labeled, nominally-dimensioned boxes, which -//! is the asset registry's last-resort tier working as intended. +//! real asset render as labeled, nominally-dimensioned boxes, which is the +//! asset registry's last-resort tier working as intended. use lab_compiler::backend::hamilton::star::profile::StarTargetProfile; +use crate::assets::AssetCatalog; use crate::scene::{Geometry, SCENE_FORMAT, Scene, SceneError, SceneNode, Semantic}; use crate::star::star_deck_scene; @@ -21,36 +22,41 @@ pub struct StationScene { pub star_profile: Option, } -/// Nominal instrument body extents by station kind. -fn station_body(kind: &str) -> Geometry { +/// Nominal instrument body extents by station kind. The STAR renders as a +/// plinth under its deck, so the deck's carriers and labware stay visible +/// above it. +fn station_extent(kind: &str) -> [f64; 3] { match kind { - // The STAR renders as a plinth under its deck, so the deck's - // carriers and labware stay visible above it. - "hamilton.star" => Geometry::Box { - x: 1400.0, - y: 700.0, - z: 60.0, - }, - "inheco.odtc" => Geometry::Box { - x: 200.0, - y: 320.0, - z: 260.0, - }, - "byonoy.absorbance96" => Geometry::Box { - x: 160.0, - y: 240.0, - z: 120.0, - }, - _ => Geometry::Box { - x: 400.0, - y: 400.0, - z: 400.0, + "hamilton.star" => [1400.0, 700.0, 60.0], + "inheco.odtc" => [200.0, 320.0, 260.0], + "byonoy.absorbance96" => [160.0, 240.0, 120.0], + _ => [400.0, 400.0, 400.0], + } +} + +/// The registry fallback chain, stated once: an asset when the facility +/// has one, the dimensioned box otherwise. +pub(crate) fn geometry_for( + assets: Option<&AssetCatalog>, + key: &str, + fallback: [f64; 3], +) -> Geometry { + match assets { + Some(catalog) => catalog.resolve(key, fallback), + None => Geometry::Box { + x: fallback[0], + y: fallback[1], + z: fallback[2], }, } } /// Builds the room scene for a set of stations, in declaration order. -pub fn workcell_scene(name: &str, stations: Vec) -> Result { +pub fn workcell_scene( + name: &str, + stations: Vec, + assets: Option<&AssetCatalog>, +) -> Result { let mut room = SceneNode::new("room", Semantic::Room, [0.0, 0.0, 0.0]); for (index, station) in stations.into_iter().enumerate() { let mut node = SceneNode::new( @@ -60,11 +66,15 @@ pub fn workcell_scene(name: &str, stations: Vec) -> Result) -> Result Result { +pub fn star_bench_scene( + name: &str, + profile: &StarTargetProfile, + assets: Option<&AssetCatalog>, +) -> Result { workcell_scene( name, vec![StationScene { @@ -84,5 +98,6 @@ pub fn star_bench_scene(name: &str, profile: &StarTargetProfile) -> Result Date: Tue, 11 Aug 2026 22:08:42 -0600 Subject: [PATCH 04/17] Lay out scenes from the facility: room, positions, rotations facility.toml gains a [room] section (kit floor and walls, or an environment asset key) and per-station position_mm/rotation_deg, with placements validated against the room bounds. lab scene --facility checks the plan's stations, resolves real meshes from the facility's assets directory, lays stations out on the floor plan, and bundles referenced assets beside the scene. Without a facility the schematic row layout renders exactly as before. --- crates/lab-cli/src/main.rs | 10 +- crates/lab-cli/src/scene.rs | 54 ++++++++-- crates/lab-cli/tests/project_workflow.rs | 102 ++++++++++++++++++ crates/lab-runfmt/src/facility.rs | 94 ++++++++++++++++ crates/lab-scene/src/workcell.rs | 59 +++++++++- .../golden-gate/facilities/main-bench.toml | 11 ++ 6 files changed, 319 insertions(+), 11 deletions(-) diff --git a/crates/lab-cli/src/main.rs b/crates/lab-cli/src/main.rs index 5b034fb..bf1749f 100644 --- a/crates/lab-cli/src/main.rs +++ b/crates/lab-cli/src/main.rs @@ -114,6 +114,10 @@ enum Command { /// Where to write scene files; defaults to the run directory. #[arg(long)] out_dir: Option, + /// Facility description that lays out the room: station positions, + /// the room shell, and real meshes from its assets directory. + #[arg(long)] + facility: Option, }, /// Print resolved package metadata and source-module names. Metadata { @@ -198,7 +202,11 @@ fn run() -> Result<()> { trace, facility, } => simulate::simulate(path, trace, facility, &output), - Command::Scene { path, out_dir } => scene::scene(path, out_dir, &output), + Command::Scene { + path, + out_dir, + facility, + } => scene::scene(path, out_dir, facility, &output), Command::Metadata { path } => commands::metadata(path, &output), Command::Update { check } => update::update(check, &output), } diff --git a/crates/lab-cli/src/scene.rs b/crates/lab-cli/src/scene.rs index cc5aca8..4c90f0b 100644 --- a/crates/lab-cli/src/scene.rs +++ b/crates/lab-cli/src/scene.rs @@ -50,9 +50,32 @@ fn deck_from_manifest(path: &Path) -> Result { Ok(profile) } -fn build_scene(directory: &Path) -> Result { +/// A facility and the asset catalog rooted beside it. +struct FacilityContext { + facility: lab_runfmt::facility::Facility, + assets: lab_scene::assets::AssetCatalog, +} + +fn load_facility_context(path: &Path) -> Result { + let facility = lab_runfmt::facility::load_facility(path)?; + let assets_dir = path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join("assets"); + Ok(FacilityContext { + facility, + assets: lab_scene::assets::AssetCatalog::new(assets_dir), + }) +} + +fn build_scene(directory: &Path, context: Option<&FacilityContext>) -> Result { + let assets = context.map(|context| &context.assets); + let facility = context.map(|context| &context.facility); if lab_runtime::workcell::is_workcell_directory(directory) { let plan = lab_runfmt::load_workcell_plan(directory)?; + if let Some(facility) = facility { + facility.check_stations(&plan.stations)?; + } let mut stations = Vec::new(); for station in &plan.stations { let star_profile = if station.kind == "hamilton.star" { @@ -69,11 +92,15 @@ fn build_scene(directory: &Path) -> Result { star_profile, }); } - let name = directory - .file_name() - .map(|name| name.to_string_lossy().into_owned()) + let name = facility + .map(|facility| facility.facility.name.clone()) + .or_else(|| { + directory + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + }) .unwrap_or_else(|| "workcell".to_string()); - Ok(workcell_scene(&name, stations, None)?) + Ok(workcell_scene(&name, stations, assets, facility)?) } else { let manifest = directory.join("automation_manifest.json"); if !manifest.is_file() { @@ -84,13 +111,24 @@ fn build_scene(directory: &Path) -> Result { } let profile = deck_from_manifest(&manifest)?; let name = profile.target.name.clone(); - Ok(star_bench_scene(&name, &profile, None)?) + Ok(star_bench_scene(&name, &profile, assets)?) } } -pub(crate) fn scene(directory: PathBuf, out_dir: Option, output: &Output) -> Result<()> { - let scene = build_scene(&directory)?; +pub(crate) fn scene( + directory: PathBuf, + out_dir: Option, + facility_path: Option, + output: &Output, +) -> Result<()> { + let context = facility_path + .as_deref() + .map(load_facility_context) + .transpose()?; + let mut scene = build_scene(&directory, context.as_ref())?; let out_dir = out_dir.unwrap_or_else(|| directory.clone()); + lab_scene::assets::bundle_assets(&mut scene, &out_dir) + .context("failed to bundle scene assets")?; std::fs::create_dir_all(&out_dir) .with_context(|| format!("failed to create {}", out_dir.display()))?; diff --git a/crates/lab-cli/tests/project_workflow.rs b/crates/lab-cli/tests/project_workflow.rs index da2f808..6e190ed 100644 --- a/crates/lab-cli/tests/project_workflow.rs +++ b/crates/lab-cli/tests/project_workflow.rs @@ -739,3 +739,105 @@ fn a_built_wave_renders_as_a_scene_with_exact_well_positions() { std::fs::remove_dir_all(&out_dir).unwrap(); } + +#[test] +fn a_facility_lays_out_the_scene_with_room_and_assets() { + let example = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../examples/golden-gate") + .canonicalize() + .unwrap(); + let out_dir = std::env::temp_dir().join(format!( + "lab-golden-gate-facility-scene-{}-{}", + std::process::id(), + line!() + )); + if out_dir.exists() { + std::fs::remove_dir_all(&out_dir).unwrap(); + } + + let output = Command::new(env!("CARGO_BIN_EXE_lab")) + .args([ + "build", + example.to_str().unwrap(), + "--target", + "workcell-star", + "--out-dir", + out_dir.to_str().unwrap(), + "--json", + ]) + .output() + .unwrap(); + assert!(output.status.success()); + let wave = out_dir.join("workcell-star/wave-001"); + + // A facility with a room, placed stations, and one stub asset. + let facility_dir = out_dir.join("facility"); + std::fs::create_dir_all(facility_dir.join("assets")).unwrap(); + std::fs::write( + facility_dir.join("assets/inheco.odtc.usda"), + "#usda 1.0\ndef Xform \"odtc\" {}\n", + ) + .unwrap(); + std::fs::write( + facility_dir.join("assets/inheco.odtc.glb"), + b"glTF-stub".as_slice(), + ) + .unwrap(); + let source = std::fs::read_to_string(example.join("facilities/main-bench.toml")).unwrap(); + std::fs::write(facility_dir.join("main-bench.toml"), source).unwrap(); + + let rendered = Command::new(env!("CARGO_BIN_EXE_lab")) + .args([ + "scene", + wave.to_str().unwrap(), + "--facility", + facility_dir.join("main-bench.toml").to_str().unwrap(), + "--json", + ]) + .output() + .unwrap(); + assert!( + rendered.status.success(), + "scene --facility failed: {}", + String::from_utf8_lossy(&rendered.stderr) + ); + + let scene_text = std::fs::read_to_string(wave.join("scene.json")).unwrap(); + assert!( + scene_text.contains("room:floor") && scene_text.contains("room:wall-back"), + "the kit room renders from [room]" + ); + assert!( + scene_text.contains("rotation_z_deg"), + "a placed station's rotation lands in the scene" + ); + assert!( + scene_text.contains("assets/inheco.odtc.glb"), + "asset paths are bundled relative to the scene" + ); + assert!( + wave.join("assets/inheco.odtc.glb").is_file() + && wave.join("assets/inheco.odtc.usda").is_file(), + "referenced assets are copied beside the scene" + ); + let usda = std::fs::read_to_string(wave.join("scene.usda")).unwrap(); + assert!( + usda.contains("prepend references = @assets/inheco.odtc.usda@"), + "the USD layer composes the asset by reference:\n{}", + &usda[..usda.len().min(400)] + ); + + // Without a facility, the schematic scene renders exactly as before. + let bare = Command::new(env!("CARGO_BIN_EXE_lab")) + .args(["scene", wave.to_str().unwrap(), "--json"]) + .output() + .unwrap(); + assert!(bare.status.success()); + let bare_text = std::fs::read_to_string(wave.join("scene.json")).unwrap(); + assert!( + !bare_text.contains("room:floor") && !bare_text.contains("asset_gltf"), + "no facility means no room shell and no meshes" + ); + + std::fs::remove_dir_all(&out_dir).unwrap(); +} diff --git a/crates/lab-runfmt/src/facility.rs b/crates/lab-runfmt/src/facility.rs index c92b2a6..6b43e3c 100644 --- a/crates/lab-runfmt/src/facility.rs +++ b/crates/lab-runfmt/src/facility.rs @@ -51,12 +51,34 @@ pub enum FacilityError { kind: String, hint: String, }, + #[error( + "facility '{facility}' places station '{station}' at ({x}, {y}) mm, outside its {width} x {depth} mm room" + )] + StationOutsideRoom { + facility: String, + station: String, + x: f64, + y: f64, + width: f64, + depth: f64, + }, + #[error( + "facility '{facility}' declares a {width} x {depth} mm room; both extents must be positive" + )] + EmptyRoom { + facility: String, + width: f64, + depth: f64, + }, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Facility { pub facility: FacilityMetadata, + /// The room the stations stand in; absent for a bare bench. + #[serde(default)] + pub room: Option, #[serde(default, rename = "station")] pub stations: Vec, #[serde(default, rename = "storage")] @@ -67,6 +89,25 @@ pub struct Facility { pub transport: FacilityTransport, } +/// The room an idealized facility is laid out in: a floor plan for the +/// scene and the bounds station positions are checked against. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Room { + pub width_mm: f64, + pub depth_mm: f64, + #[serde(default = "default_room_height")] + pub height_mm: f64, + /// An asset key for a modeled or scanned environment; without one the + /// scene renders a kit room (floor and walls) at these dimensions. + #[serde(default)] + pub environment: Option, +} + +fn default_room_height() -> f64 { + 3000.0 +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct FacilityMetadata { @@ -89,6 +130,13 @@ pub struct FacilityStation { /// compiled into artifacts. #[serde(default)] pub address: Option, + /// Floor position of the station's origin, in room millimeters. + /// Stations without one are laid out in declaration order. + #[serde(default)] + pub position_mm: Option<[f64; 2]>, + /// Counterclockwise rotation about the station's origin. + #[serde(default)] + pub rotation_deg: Option, } /// One storage location and the stock it holds. Stock is inventory state, @@ -146,6 +194,15 @@ impl Facility { } fn validate(&self) -> Result<(), FacilityError> { + if let Some(room) = &self.room + && (room.width_mm <= 0.0 || room.depth_mm <= 0.0) + { + return Err(FacilityError::EmptyRoom { + facility: self.facility.name.clone(), + width: room.width_mm, + depth: room.depth_mm, + }); + } let mut station_names = BTreeSet::new(); for station in &self.stations { if !station_names.insert(station.name.as_str()) { @@ -154,6 +211,18 @@ impl Facility { station: station.name.clone(), }); } + if let (Some(room), Some([x, y])) = (&self.room, &station.position_mm) + && (!(0.0..=room.width_mm).contains(x) || !(0.0..=room.depth_mm).contains(y)) + { + return Err(FacilityError::StationOutsideRoom { + facility: self.facility.name.clone(), + station: station.name.clone(), + x: *x, + y: *y, + width: room.width_mm, + depth: room.depth_mm, + }); + } } let mut storage_names = BTreeSet::new(); for storage in &self.storage { @@ -334,6 +403,31 @@ walk_seconds = 45.0 )); } + #[test] + fn a_room_bounds_the_stations_placed_in_it() { + let placed = format!("{MAIN_BENCH}\n[room]\nwidth_mm = 8000.0\ndepth_mm = 6000.0\n"); + let facility = Facility::parse("f.toml", &placed).expect("unplaced stations are fine"); + assert_eq!(facility.room.as_ref().unwrap().height_mm, 3000.0); + + let inside = placed.replace( + "name = \"star-1\"\nkind = \"hamilton.star\"", + "name = \"star-1\"\nkind = \"hamilton.star\"\nposition_mm = [1200.0, 2400.0]\nrotation_deg = 90.0", + ); + let facility = Facility::parse("f.toml", &inside).unwrap(); + let star = facility.station("star-1").unwrap(); + assert_eq!(star.position_mm, Some([1200.0, 2400.0])); + assert_eq!(star.rotation_deg, Some(90.0)); + + let outside = placed.replace( + "name = \"star-1\"\nkind = \"hamilton.star\"", + "name = \"star-1\"\nkind = \"hamilton.star\"\nposition_mm = [9000.0, 2400.0]", + ); + assert!(matches!( + Facility::parse("f.toml", &outside), + Err(FacilityError::StationOutsideRoom { .. }) + )); + } + #[test] fn a_misspelled_key_must_not_silently_vanish() { let misspelled = MAIN_BENCH.replace("materials =", "material ="); diff --git a/crates/lab-scene/src/workcell.rs b/crates/lab-scene/src/workcell.rs index 96002d1..606178a 100644 --- a/crates/lab-scene/src/workcell.rs +++ b/crates/lab-scene/src/workcell.rs @@ -51,26 +51,80 @@ pub(crate) fn geometry_for( } } -/// Builds the room scene for a set of stations, in declaration order. +/// Kit-room slab thickness. +const SLAB_MM: f64 = 80.0; + +/// The room shell an idealized facility renders: an environment asset +/// when the facility names one, a kit floor and three walls otherwise +/// (the front stays open so a default camera sees in). +fn room_shell(room: &lab_runfmt::facility::Room, assets: Option<&AssetCatalog>) -> Vec { + let (width, depth, height) = (room.width_mm, room.depth_mm, room.height_mm); + if let Some(key) = &room.environment { + return vec![ + SceneNode::new("room:environment", Semantic::Room, [0.0, 0.0, 0.0]) + .with_geometry(geometry_for(assets, key, [width, depth, height])), + ]; + } + let slab = |id: &str, translation: [f64; 3], extent: [f64; 3]| { + SceneNode::new(id, Semantic::Room, translation).with_geometry(Geometry::Box { + x: extent[0], + y: extent[1], + z: extent[2], + }) + }; + vec![ + slab("room:floor", [0.0, 0.0, -SLAB_MM], [width, depth, SLAB_MM]), + slab( + "room:wall-back", + [0.0, depth, 0.0], + [width, SLAB_MM, height], + ), + slab( + "room:wall-left", + [-SLAB_MM, 0.0, 0.0], + [SLAB_MM, depth, height], + ), + slab( + "room:wall-right", + [width, 0.0, 0.0], + [SLAB_MM, depth, height], + ), + ] +} + +/// Builds the room scene for a set of stations. A facility supplies the +/// layout: per-station floor positions and rotations, and the room shell. +/// Without one, stations line up in declaration order on a bare floor. pub fn workcell_scene( name: &str, stations: Vec, assets: Option<&AssetCatalog>, + facility: Option<&lab_runfmt::facility::Facility>, ) -> Result { let mut room = SceneNode::new("room", Semantic::Room, [0.0, 0.0, 0.0]); + if let Some(shell) = facility.and_then(|facility| facility.room.as_ref()) { + room.children.extend(room_shell(shell, assets)); + } for (index, station) in stations.into_iter().enumerate() { + let placed = facility.and_then(|facility| facility.station(&station.name)); + let position = placed + .and_then(|declaration| declaration.position_mm) + .unwrap_or([index as f64 * STATION_PITCH_MM, 0.0]); let mut node = SceneNode::new( station.name.clone(), Semantic::Station { station_kind: station.kind.clone(), }, - [index as f64 * STATION_PITCH_MM, 0.0, BENCH_TOP_MM], + [position[0], position[1], BENCH_TOP_MM], ) .with_geometry(geometry_for( assets, &station.kind, station_extent(&station.kind), )); + node.rotation_z_deg = placed + .and_then(|declaration| declaration.rotation_deg) + .unwrap_or(0.0); if let Some(profile) = &station.star_profile { // The deck scene is in the machine's own frame, which shares // the station node's origin. @@ -99,5 +153,6 @@ pub fn star_bench_scene( star_profile: Some(profile.clone()), }], assets, + None, ) } diff --git a/examples/golden-gate/facilities/main-bench.toml b/examples/golden-gate/facilities/main-bench.toml index 099287b..502e18c 100644 --- a/examples/golden-gate/facilities/main-bench.toml +++ b/examples/golden-gate/facilities/main-bench.toml @@ -7,15 +7,26 @@ [facility] name = "main-bench" +# The room the scene renders: a kit floor and walls at these dimensions. +# An `environment` asset key would replace the kit with a modeled or +# scanned room. +[room] +width_mm = 6000.0 +depth_mm = 4000.0 +height_mm = 3000.0 + [[station]] name = "star-1" kind = "hamilton.star" profile = "hamilton-star" +position_mm = [1200.0, 2800.0] [[station]] name = "odtc-1" kind = "inheco.odtc" address = "169.254.10.40:8080" +position_mm = [3400.0, 2900.0] +rotation_deg = -15.0 [[storage]] name = "fridge-a" From 67d87294f7982a7bbbf6767a57a41e2e9cd37899 Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Tue, 11 Aug 2026 22:35:15 -0600 Subject: [PATCH 05/17] Animate the USD layer from the simulation trace lab scene --animated writes time samples over the same emission the static layer uses: one timecode per simulated second, labware holding home until each confirmed handoff and arriving on the destination's top two seconds later, and a synthesized pipetting head per liquid handler that follows the frames' deck coordinates and hides between programs. Every geometry prim now binds a UsdPreviewSurface material by semantic, so Omniverse, Blender, and usdview shade the bench without extra work. Animated labware under a rotated ancestor is refused, not mis-posed. --- crates/lab-cli/src/main.rs | 7 +- crates/lab-cli/src/scene.rs | 30 +- crates/lab-cli/tests/project_workflow.rs | 82 +++++ crates/lab-scene/src/animate.rs | 321 +++++++++++++++++ crates/lab-scene/src/lib.rs | 1 + crates/lab-scene/src/usda.rs | 441 +++++++++++++++++------ 6 files changed, 760 insertions(+), 122 deletions(-) create mode 100644 crates/lab-scene/src/animate.rs diff --git a/crates/lab-cli/src/main.rs b/crates/lab-cli/src/main.rs index bf1749f..c13f319 100644 --- a/crates/lab-cli/src/main.rs +++ b/crates/lab-cli/src/main.rs @@ -118,6 +118,10 @@ enum Command { /// the room shell, and real meshes from its assets directory. #[arg(long)] facility: Option, + /// Animate the USD layer from this package's sim-trace.json, so + /// USD tools play the simulated run on their timeline. + #[arg(long)] + animated: bool, }, /// Print resolved package metadata and source-module names. Metadata { @@ -206,7 +210,8 @@ fn run() -> Result<()> { path, out_dir, facility, - } => scene::scene(path, out_dir, facility, &output), + animated, + } => scene::scene(path, out_dir, facility, animated, &output), Command::Metadata { path } => commands::metadata(path, &output), Command::Update { check } => update::update(check, &output), } diff --git a/crates/lab-cli/src/scene.rs b/crates/lab-cli/src/scene.rs index 4c90f0b..718223b 100644 --- a/crates/lab-cli/src/scene.rs +++ b/crates/lab-cli/src/scene.rs @@ -115,10 +115,33 @@ fn build_scene(directory: &Path, context: Option<&FacilityContext>) -> Result Result { + let path = directory.join("sim-trace.json"); + let text = std::fs::read_to_string(&path).with_context(|| { + format!( + "no trace at {}; run `lab simulate` on this package first, then `lab scene --animated`", + path.display() + ) + })?; + let trace: lab_runfmt::SimTraceDocument = serde_json::from_str(&text) + .with_context(|| format!("failed to parse {}", path.display()))?; + if trace.format != lab_runfmt::SIM_TRACE_FORMAT { + bail!( + "{} declares format '{}'; this reader expects '{}'", + path.display(), + trace.format, + lab_runfmt::SIM_TRACE_FORMAT + ); + } + Ok(trace) +} + pub(crate) fn scene( directory: PathBuf, out_dir: Option, facility_path: Option, + animated: bool, output: &Output, ) -> Result<()> { let context = facility_path @@ -126,6 +149,7 @@ pub(crate) fn scene( .map(load_facility_context) .transpose()?; let mut scene = build_scene(&directory, context.as_ref())?; + let trace = animated.then(|| load_trace(&directory)).transpose()?; let out_dir = out_dir.unwrap_or_else(|| directory.clone()); lab_scene::assets::bundle_assets(&mut scene, &out_dir) .context("failed to bundle scene assets")?; @@ -139,7 +163,11 @@ pub(crate) fn scene( std::fs::write(&gltf_path, render_gltf(&scene)) .with_context(|| format!("failed to write {}", gltf_path.display()))?; let usda_path = out_dir.join("scene.usda"); - std::fs::write(&usda_path, render_usda(&scene)) + let usda = match &trace { + Some(trace) => lab_scene::animate::render_usda_animated(&scene, trace)?, + None => render_usda(&scene), + }; + std::fs::write(&usda_path, usda) .with_context(|| format!("failed to write {}", usda_path.display()))?; let mut nodes = 0usize; diff --git a/crates/lab-cli/tests/project_workflow.rs b/crates/lab-cli/tests/project_workflow.rs index 6e190ed..c45cbb8 100644 --- a/crates/lab-cli/tests/project_workflow.rs +++ b/crates/lab-cli/tests/project_workflow.rs @@ -841,3 +841,85 @@ fn a_facility_lays_out_the_scene_with_room_and_assets() { std::fs::remove_dir_all(&out_dir).unwrap(); } + +#[test] +fn an_animated_scene_plays_the_simulated_run_on_the_usd_timeline() { + let example = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../examples/golden-gate") + .canonicalize() + .unwrap(); + let out_dir = std::env::temp_dir().join(format!( + "lab-golden-gate-animated-{}-{}", + std::process::id(), + line!() + )); + if out_dir.exists() { + std::fs::remove_dir_all(&out_dir).unwrap(); + } + + let output = Command::new(env!("CARGO_BIN_EXE_lab")) + .args([ + "build", + example.to_str().unwrap(), + "--target", + "workcell-star", + "--out-dir", + out_dir.to_str().unwrap(), + "--json", + ]) + .output() + .unwrap(); + assert!(output.status.success()); + let wave = out_dir.join("workcell-star/wave-001"); + + // Animation requires a trace; the error names the missing step. + let premature = Command::new(env!("CARGO_BIN_EXE_lab")) + .args(["scene", wave.to_str().unwrap(), "--animated"]) + .output() + .unwrap(); + assert!(!premature.status.success()); + assert!( + String::from_utf8_lossy(&premature.stderr).contains("lab simulate"), + "the error points at the missing step" + ); + + let simulated = Command::new(env!("CARGO_BIN_EXE_lab")) + .args(["simulate", wave.to_str().unwrap(), "--json"]) + .output() + .unwrap(); + assert!(simulated.status.success()); + + let rendered = Command::new(env!("CARGO_BIN_EXE_lab")) + .args(["scene", wave.to_str().unwrap(), "--animated", "--json"]) + .output() + .unwrap(); + assert!( + rendered.status.success(), + "scene --animated failed: {}", + String::from_utf8_lossy(&rendered.stderr) + ); + let usda = std::fs::read_to_string(wave.join("scene.usda")).unwrap(); + let trace: Value = + serde_json::from_str(&std::fs::read_to_string(wave.join("sim-trace.json")).unwrap()) + .unwrap(); + let total = trace["summary"]["total_seconds"].as_f64().unwrap(); + assert!( + usda.contains(&format!("endTimeCode = {total}")), + "the stage timeline spans the simulated run" + ); + assert!(usda.contains("xformOp:translate.timeSamples")); + assert!( + usda.contains("def Xform \"pipetting_head\""), + "the liquid handler grows a head that follows its frames" + ); + assert!( + usda.contains("token visibility.timeSamples"), + "the head hides between programs" + ); + assert!( + usda.contains("def Material \"lab_plate\""), + "preview-surface materials ride along" + ); + + std::fs::remove_dir_all(&out_dir).unwrap(); +} diff --git a/crates/lab-scene/src/animate.rs b/crates/lab-scene/src/animate.rs new file mode 100644 index 0000000..bd99ae4 --- /dev/null +++ b/crates/lab-scene/src/animate.rs @@ -0,0 +1,321 @@ +//! Trace-driven USD animation: the same events the web player interprets, +//! written as time samples so Omniverse, Blender, and usdview play the +//! actual run. +//! +//! One timecode is one simulated second. Labware translates hold their +//! home until each confirmed handoff and arrive at the destination's top +//! two seconds later; a synthesized pipetting head per liquid handler +//! follows the frames' deck coordinates and is visible only while its +//! program runs. Door and thermal state are not animated yet: they are +//! material changes, and the referenced-asset material story owns them. + +use std::collections::BTreeMap; + +use lab_runfmt::{RunEvent, SimTraceDocument}; + +use crate::scene::{Geometry, Scene, SceneError, SceneNode, Semantic}; +use crate::usda::{EmitContext, HeadTrack, render_usda_with}; + +/// Seconds a carried plate takes to arrive after its handoff confirms. +const HANDOFF_TRAVEL_SECONDS: f64 = 2.0; +/// The pipetting head's working height over the deck, in millimeters. +const HEAD_HEIGHT_MM: f64 = 260.0; +/// Clearance between a seated plate and the station body under it. +const SEAT_CLEARANCE_MM: f64 = 5.0; + +struct NodeFacts { + origin: [f64; 3], + local: [f64; 3], + rotated_ancestry: bool, + body_height: f64, +} + +/// Translation-accumulated facts per node id, with a flag for any rotation +/// in the ancestor chain (excluding the node's own rotation). +fn collect_facts(scene: &Scene) -> BTreeMap { + let mut facts = BTreeMap::new(); + fn visit( + node: &SceneNode, + origin: [f64; 3], + rotated_ancestry: bool, + facts: &mut BTreeMap, + ) { + let here = [ + origin[0] + node.translation[0], + origin[1] + node.translation[1], + origin[2] + node.translation[2], + ]; + let body_height = match &node.geometry { + Some(Geometry::Box { z, .. }) => *z, + Some(Geometry::Cylinder { height, .. }) => *height, + Some(Geometry::Mesh { fallback, .. }) => fallback[2], + None => 0.0, + }; + facts.insert( + node.id.clone(), + NodeFacts { + origin: here, + local: node.translation, + rotated_ancestry, + body_height, + }, + ); + let rotated_below = rotated_ancestry || node.rotation_z_deg != 0.0; + for child in &node.children { + visit(child, here, rotated_below, facts); + } + } + visit(&scene.root, [0.0, 0.0, 0.0], false, &mut facts); + facts +} + +/// Renders the animated `.usda` layer for a scene and the trace of one +/// simulated run over it. +pub fn render_usda_animated(scene: &Scene, trace: &SimTraceDocument) -> Result { + let facts = collect_facts(scene); + let mut labware_tracks: BTreeMap> = BTreeMap::new(); + let mut head_tracks: BTreeMap = BTreeMap::new(); + // The station whose program most recently emitted a frame, per head. + let mut heads_visible: BTreeMap = BTreeMap::new(); + + for timed in &trace.events { + match &timed.event { + RunEvent::LabwareMoved { labware, to, .. } => { + let Some(moving) = facts.get(labware) else { + continue; + }; + let Some(station) = facts.get(to) else { + continue; + }; + if moving.rotated_ancestry { + return Err(SceneError::Resolve { + context: labware.clone(), + message: format!( + "labware '{labware}' sits under a rotated ancestor; animated moves under rotation are not supported yet — drop rotation_deg from that station" + ), + }); + } + let track = labware_tracks + .entry(labware.clone()) + .or_insert_with(|| vec![(0.0, moving.local)]); + // Hold wherever the plate was, then travel to the seat. + let held = track.last().expect("tracks start seeded").1; + track.push((timed.t, held)); + let parent_origin = [ + moving.origin[0] - moving.local[0], + moving.origin[1] - moving.local[1], + moving.origin[2] - moving.local[2], + ]; + let seat_world = [ + station.origin[0], + station.origin[1], + station.origin[2] + station.body_height + SEAT_CLEARANCE_MM, + ]; + track.push(( + timed.t + HANDOFF_TRAVEL_SECONDS, + [ + seat_world[0] - parent_origin[0], + seat_world[1] - parent_origin[1], + seat_world[2] - parent_origin[2], + ], + )); + } + RunEvent::Frame { + station, + x_mm: Some(x), + y_mm: Some(y), + .. + } => { + let track = head_tracks.entry(station.clone()).or_default(); + if !heads_visible.get(station).copied().unwrap_or(false) { + track.visibility.push((timed.t, true)); + heads_visible.insert(station.clone(), true); + } + track.positions.push((timed.t, [*x, *y, HEAD_HEIGHT_MM])); + } + RunEvent::NodeCompleted { .. } => { + for (station, visible) in heads_visible.iter_mut() { + if *visible { + if let Some(track) = head_tracks.get_mut(station) { + track.visibility.push((timed.t, false)); + } + *visible = false; + } + } + } + _ => {} + } + } + + let context = EmitContext { + materials: true, + end_time_code: Some(trace.summary.total_seconds), + labware_tracks, + head_tracks, + }; + Ok(render_usda_with(scene, &context)) +} + +/// The semantic a head prim's parts render with, shared with the static +/// exporter's material table. +pub(crate) fn head_semantic() -> Semantic { + Semantic::Station { + station_kind: "pipetting-head".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use lab_runfmt::{ProgramExtent, SimSummary, TimedEvent}; + + use crate::scene::{Geometry, SCENE_FORMAT}; + + fn timed(t: f64, event: RunEvent) -> TimedEvent { + TimedEvent { t, event } + } + + fn test_scene() -> Scene { + let plate = SceneNode::new( + "reaction_plate", + Semantic::Labware { + catalog: "pcr_plate_96".to_string(), + }, + [100.0, 50.0, 0.0], + ) + .with_geometry(Geometry::Box { + x: 127.76, + y: 85.48, + z: 16.1, + }); + let mut star = SceneNode::new( + "star-1", + Semantic::Station { + station_kind: "hamilton.star".to_string(), + }, + [0.0, 0.0, 900.0], + ) + .with_geometry(Geometry::Box { + x: 1400.0, + y: 700.0, + z: 60.0, + }); + star.children.push(plate); + let odtc = SceneNode::new( + "odtc-1", + Semantic::Station { + station_kind: "inheco.odtc".to_string(), + }, + [2000.0, 0.0, 900.0], + ) + .with_geometry(Geometry::Box { + x: 200.0, + y: 320.0, + z: 260.0, + }); + let mut room = SceneNode::new("room", Semantic::Room, [0.0, 0.0, 0.0]); + room.children.push(star); + room.children.push(odtc); + Scene { + format: SCENE_FORMAT.to_string(), + name: "test".to_string(), + root: room, + } + } + + fn test_trace() -> SimTraceDocument { + SimTraceDocument { + format: lab_runfmt::SIM_TRACE_FORMAT.to_string(), + plan: "plan.workcell.json".to_string(), + durations: "default-v0".to_string(), + events: vec![ + timed( + 0.0, + RunEvent::ProgramStarted { + station: "star-1".to_string(), + title: "assembly".to_string(), + extent: ProgramExtent::Frames { frames: 2 }, + }, + ), + timed( + 1.0, + RunEvent::Frame { + station: "star-1".to_string(), + index: 1, + description: "pick up tips".to_string(), + x_mm: Some(117.9), + y_mm: Some(241.8), + }, + ), + timed( + 9.0, + RunEvent::Frame { + station: "star-1".to_string(), + index: 2, + description: "aspirate".to_string(), + x_mm: Some(300.0), + y_mm: Some(180.0), + }, + ), + timed( + 20.0, + RunEvent::NodeCompleted { + id: "assembly_run".to_string(), + }, + ), + timed( + 120.0, + RunEvent::LabwareMoved { + labware: "reaction_plate".to_string(), + from: "star-1".to_string(), + to: "odtc-1".to_string(), + }, + ), + ], + summary: SimSummary { + total_seconds: 500.0, + ..SimSummary::default() + }, + } + } + + #[test] + fn the_animated_layer_carries_time_samples_and_a_head() { + let text = render_usda_animated(&test_scene(), &test_trace()).unwrap(); + assert!(text.contains("endTimeCode = 500"), "{text}"); + assert!(text.contains("timeCodesPerSecond = 1")); + assert!( + text.contains("xformOp:translate.timeSamples"), + "labware animates" + ); + // The plate holds home (0..120), then arrives on the ODTC top: + // odtc origin (2000, 0, 900) + body 260 + clearance 5, expressed in + // the plate's parent frame (star-1 at (0, 0, 900)). + assert!( + text.contains("122: (2000, 0, 265)"), + "the seat sample lands on the station top:\n{}", + text.lines() + .filter(|line| line.contains("timeSamples") || line.contains("122:")) + .collect::>() + .join("\n") + ); + assert!(text.contains("def Xform \"pipetting_head\"")); + assert!( + text.contains("token visibility.timeSamples"), + "the head hides between programs" + ); + assert!( + text.contains("1: (117.9, 241.8, 260)"), + "head samples follow frame coordinates" + ); + } + + #[test] + fn animated_labware_under_a_rotated_station_is_refused() { + let mut scene = test_scene(); + scene.root.children[0].rotation_z_deg = 15.0; + let error = render_usda_animated(&scene, &test_trace()) + .expect_err("rotation under animation is refused"); + assert!(error.to_string().contains("rotated"), "{error}"); + } +} diff --git a/crates/lab-scene/src/lib.rs b/crates/lab-scene/src/lib.rs index dc2ea39..a1df91c 100644 --- a/crates/lab-scene/src/lib.rs +++ b/crates/lab-scene/src/lib.rs @@ -12,6 +12,7 @@ //! dimensions from a side table, because the planning catalog carries //! anchor points, not bounding boxes. +pub mod animate; pub mod assets; pub mod dims; pub mod gltf; diff --git a/crates/lab-scene/src/usda.rs b/crates/lab-scene/src/usda.rs index 8ce1061..c9bbe9e 100644 --- a/crates/lab-scene/src/usda.rs +++ b/crates/lab-scene/src/usda.rs @@ -1,26 +1,77 @@ //! USD export: a plain-text `.usda` layer for Isaac Sim, Omniverse, and //! Unreal. Same scene, same millimeters; USD carries the unit and up-axis //! declaration itself, so no root transform is needed. +//! +//! Every geometry prim binds a `UsdPreviewSurface` material by semantic — +//! the portable interchange surface Blender, Omniverse, and usdview all +//! honor. Referenced assets carry their own richer material networks +//! untouched. The animated variant (see `animate`) adds time samples over +//! the same emission. +use std::collections::BTreeMap; use std::fmt::Write; -use crate::scene::{Geometry, Scene, SceneNode}; +use crate::scene::{Geometry, Scene, SceneNode, Semantic}; -/// Renders the scene as a `.usda` text layer: one `Xform` per node, with -/// `Cube`/`Cylinder` geometry prims scaled to the stated extents. +/// A pipetting head's motion: positions per frame event, and the moments +/// it appears and hides. +#[derive(Clone, Debug, Default)] +pub(crate) struct HeadTrack { + pub positions: Vec<(f64, [f64; 3])>, + pub visibility: Vec<(f64, bool)>, +} + +/// What one emission run carries beyond the scene itself. +#[derive(Default)] +pub(crate) struct EmitContext { + /// Emit the material scope and bind geometry prims to it. + pub materials: bool, + /// Animated stage: `endTimeCode` in simulated seconds. + pub end_time_code: Option, + /// Labware translate tracks, keyed by node id, in parent-local mm. + pub labware_tracks: BTreeMap>, + /// Pipetting-head tracks, keyed by the station node id they ride under. + pub head_tracks: BTreeMap, +} + +/// Renders the scene as a static `.usda` text layer: one `Xform` per node, +/// with `Cube`/`Cylinder` geometry prims scaled to the stated extents and +/// asset references composed in. pub fn render_usda(scene: &Scene) -> String { + render_usda_with( + scene, + &EmitContext { + materials: true, + ..EmitContext::default() + }, + ) +} + +pub(crate) fn render_usda_with(scene: &Scene, context: &EmitContext) -> String { let mut text = String::new(); - let _ = writeln!( + let root = prim_name(&scene.root.id); + let _ = writeln!(text, "#usda 1.0\n("); + let _ = writeln!(text, " defaultPrim = \"{root}\""); + let _ = writeln!(text, " metersPerUnit = 0.001"); + let _ = writeln!(text, " upAxis = \"Z\""); + if let Some(end) = context.end_time_code { + let _ = writeln!(text, " startTimeCode = 0"); + let _ = writeln!(text, " endTimeCode = {end}"); + let _ = writeln!(text, " timeCodesPerSecond = 1"); + let _ = writeln!(text, " framesPerSecond = 1"); + } + let _ = writeln!(text, ")\n"); + let mut emitter = Emitter { + context, + root: root.clone(), text, - "#usda 1.0\n(\n defaultPrim = \"{}\"\n metersPerUnit = 0.001\n upAxis = \"Z\"\n)\n", - prim_name(&scene.root.id) - ); - emit_node(&scene.root, 0, &mut text); - text + }; + emitter.node(&scene.root, 0); + emitter.text } /// USD prim names are identifiers; scene ids carry `/`, `:`, `#`. -fn prim_name(id: &str) -> String { +pub(crate) fn prim_name(id: &str) -> String { let mut name: String = id .chars() .map(|character| { @@ -41,131 +92,272 @@ fn prim_name(id: &str) -> String { name } -fn indent(text: &mut String, depth: usize) { - for _ in 0..depth { - text.push_str(" "); +/// The material name a semantic renders with, mirroring the glTF table. +fn material_name(semantic: &Semantic, id: &str) -> &'static str { + match semantic { + Semantic::Deck => "lab_deck", + Semantic::Carrier { .. } => "lab_carrier", + Semantic::Labware { catalog } if catalog.contains("tip") => "lab_tips", + Semantic::Labware { .. } => "lab_plate", + Semantic::Well { .. } if id.contains("tip") => "lab_tips", + Semantic::Well { .. } => "lab_well", + Semantic::Room => "lab_room", + Semantic::Station { .. } | Semantic::Site { .. } => "lab_station", } } -/// A USD Cube spans [-size/2, size/2]; scale a unit cube and seat its -/// minimum corner on the node origin. -fn emit_fallback_box(extent: &[f64; 3], depth: usize, text: &mut String) { - let (x, y, z) = (extent[0], extent[1], extent[2]); - indent(text, depth + 1); - let _ = writeln!(text, "def Cube \"geometry\""); - indent(text, depth + 1); - let _ = writeln!(text, "{{"); - indent(text, depth + 2); - let _ = writeln!(text, "double size = 1"); - indent(text, depth + 2); - let _ = writeln!( - text, - "double3 xformOp:translate = ({}, {}, {})", - x / 2.0, - y / 2.0, - z / 2.0 - ); - indent(text, depth + 2); - let _ = writeln!(text, "float3 xformOp:scale = ({x}, {y}, {z})"); - indent(text, depth + 2); - let _ = writeln!( - text, - "uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:scale\"]" - ); - indent(text, depth + 1); - let _ = writeln!(text, "}}"); +/// `(name, diffuse rgb, roughness, metallic, opacity)` per material. +const MATERIALS: [(&str, [f64; 3], f64, f64, f64); 7] = [ + ("lab_deck", [0.55, 0.56, 0.58], 0.9, 0.4, 1.0), + ("lab_carrier", [0.33, 0.34, 0.38], 0.55, 0.8, 1.0), + ("lab_plate", [0.92, 0.92, 0.94], 0.5, 0.0, 1.0), + ("lab_tips", [0.90, 0.60, 0.20], 0.6, 0.0, 1.0), + ("lab_well", [0.30, 0.55, 0.90], 0.3, 0.0, 0.45), + ("lab_station", [0.62, 0.64, 0.68], 0.85, 0.3, 1.0), + ("lab_room", [0.82, 0.82, 0.80], 0.95, 0.0, 1.0), +]; + +struct Emitter<'context> { + context: &'context EmitContext, + root: String, + text: String, } -fn emit_node(node: &SceneNode, depth: usize, text: &mut String) { - indent(text, depth); - let _ = writeln!(text, "def Xform \"{}\" (", prim_name(&node.id)); - indent(text, depth); - let _ = writeln!( - text, - " customData = {{ string labId = \"{}\" }}", - node.id - ); - indent(text, depth); - let _ = writeln!(text, ")"); - indent(text, depth); - let _ = writeln!(text, "{{"); - indent(text, depth + 1); - let _ = writeln!( - text, - "double3 xformOp:translate = ({}, {}, {})", - node.translation[0], node.translation[1], node.translation[2] - ); - if node.rotation_z_deg != 0.0 { - indent(text, depth + 1); - let _ = writeln!(text, "double xformOp:rotateZ = {}", node.rotation_z_deg); - indent(text, depth + 1); - let _ = writeln!( - text, - "uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:rotateZ\"]" +impl Emitter<'_> { + fn indent(&mut self, depth: usize) { + for _ in 0..depth { + self.text.push_str(" "); + } + } + + fn line(&mut self, depth: usize, line: &str) { + self.indent(depth); + let _ = writeln!(self.text, "{line}"); + } + + fn materials_scope(&mut self, depth: usize) { + self.line(depth, "def Scope \"Materials\""); + self.line(depth, "{"); + for (name, diffuse, roughness, metallic, opacity) in MATERIALS { + let root = self.root.clone(); + self.line(depth + 1, &format!("def Material \"{name}\"")); + self.line(depth + 1, "{"); + self.line( + depth + 2, + &format!( + "token outputs:surface.connect = " + ), + ); + self.line(depth + 2, "def Shader \"surface\""); + self.line(depth + 2, "{"); + self.line(depth + 3, "uniform token info:id = \"UsdPreviewSurface\""); + self.line( + depth + 3, + &format!( + "color3f inputs:diffuseColor = ({}, {}, {})", + diffuse[0], diffuse[1], diffuse[2] + ), + ); + self.line(depth + 3, &format!("float inputs:roughness = {roughness}")); + self.line(depth + 3, &format!("float inputs:metallic = {metallic}")); + self.line(depth + 3, &format!("float inputs:opacity = {opacity}")); + self.line(depth + 3, "token outputs:surface"); + self.line(depth + 2, "}"); + self.line(depth + 1, "}"); + } + self.line(depth, "}"); + } + + fn binding(&mut self, depth: usize, semantic: &Semantic, id: &str) { + if self.context.materials { + let name = material_name(semantic, id); + let root = self.root.clone(); + self.line( + depth, + &format!("rel material:binding = "), + ); + } + } + + fn time_samples(&mut self, depth: usize, samples: &[(f64, [f64; 3])]) { + self.line(depth, "double3 xformOp:translate.timeSamples = {"); + for (t, value) in samples { + self.line( + depth + 1, + &format!("{t}: ({}, {}, {}),", value[0], value[1], value[2]), + ); + } + self.line(depth, "}"); + } + + /// A USD Cube spans [-size/2, size/2]; scale a unit cube and seat its + /// minimum corner on the node origin. + fn fallback_box(&mut self, depth: usize, extent: &[f64; 3], semantic: &Semantic, id: &str) { + let (x, y, z) = (extent[0], extent[1], extent[2]); + self.line(depth + 1, "def Cube \"geometry\""); + self.line(depth + 1, "{"); + self.binding(depth + 2, semantic, id); + self.line(depth + 2, "double size = 1"); + self.line( + depth + 2, + &format!( + "double3 xformOp:translate = ({}, {}, {})", + x / 2.0, + y / 2.0, + z / 2.0 + ), ); - } else { - indent(text, depth + 1); - let _ = writeln!( - text, - "uniform token[] xformOpOrder = [\"xformOp:translate\"]" + self.line( + depth + 2, + &format!("float3 xformOp:scale = ({x}, {y}, {z})"), ); + self.line( + depth + 2, + "uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:scale\"]", + ); + self.line(depth + 1, "}"); } - if let Some(geometry) = &node.geometry { - match geometry { - Geometry::Box { x, y, z } => { - emit_fallback_box(&[*x, *y, *z], depth, text); - } - Geometry::Cylinder { diameter, height } => { - indent(text, depth + 1); - let _ = writeln!(text, "def Cylinder \"geometry\""); - indent(text, depth + 1); - let _ = writeln!(text, "{{"); - indent(text, depth + 2); - let _ = writeln!(text, "uniform token axis = \"Z\""); - indent(text, depth + 2); - let _ = writeln!(text, "double height = {height}"); - indent(text, depth + 2); - let _ = writeln!(text, "double radius = {}", diameter / 2.0); - indent(text, depth + 2); - let _ = writeln!(text, "double3 xformOp:translate = (0, 0, {})", height / 2.0); - indent(text, depth + 2); - let _ = writeln!( - text, - "uniform token[] xformOpOrder = [\"xformOp:translate\"]" - ); - indent(text, depth + 1); - let _ = writeln!(text, "}}"); + fn head_prim(&mut self, depth: usize, track: &HeadTrack) { + let semantic = crate::animate::head_semantic(); + self.line(depth + 1, "def Xform \"pipetting_head\""); + self.line(depth + 1, "{"); + if !track.positions.is_empty() { + self.time_samples(depth + 2, &track.positions); + self.line( + depth + 2, + "uniform token[] xformOpOrder = [\"xformOp:translate\"]", + ); + } + if !track.visibility.is_empty() { + self.line(depth + 2, "token visibility.timeSamples = {"); + for (t, visible) in &track.visibility { + let value = if *visible { "inherited" } else { "invisible" }; + self.line(depth + 3, &format!("{t}: \"{value}\",")); } - Geometry::Mesh { usd, fallback, .. } => match usd { - // The asset layer carries its own geometry and materials; - // referencing it composes it under this prim. - Some(path) => { - indent(text, depth + 1); - let _ = writeln!(text, "def Xform \"geometry\" ("); - indent(text, depth + 1); - let _ = writeln!(text, " prepend references = @{path}@"); - indent(text, depth + 1); - let _ = writeln!(text, ")"); - indent(text, depth + 1); - let _ = writeln!(text, "{{"); - indent(text, depth + 1); - let _ = writeln!(text, "}}"); + self.line(depth + 2, "}"); + } + // The carriage block and the tip below it, centered on the head. + self.line(depth + 2, "def Cube \"carriage\""); + self.line(depth + 2, "{"); + self.binding(depth + 3, &semantic, "pipetting_head"); + self.line(depth + 3, "double size = 1"); + self.line(depth + 3, "double3 xformOp:translate = (0, 0, 55)"); + self.line(depth + 3, "float3 xformOp:scale = (60, 60, 110)"); + self.line( + depth + 3, + "uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:scale\"]", + ); + self.line(depth + 2, "}"); + self.line(depth + 2, "def Cylinder \"tip\""); + self.line(depth + 2, "{"); + self.binding(depth + 3, &semantic, "pipetting_head"); + self.line(depth + 3, "uniform token axis = \"Z\""); + self.line(depth + 3, "double height = 70"); + self.line(depth + 3, "double radius = 2.5"); + self.line(depth + 3, "double3 xformOp:translate = (0, 0, -35)"); + self.line( + depth + 3, + "uniform token[] xformOpOrder = [\"xformOp:translate\"]", + ); + self.line(depth + 2, "}"); + self.line(depth + 1, "}"); + } + + fn node(&mut self, node: &SceneNode, depth: usize) { + let name = prim_name(&node.id); + self.line(depth, &format!("def Xform \"{name}\" (")); + self.line( + depth, + &format!(" customData = {{ string labId = \"{}\" }}", node.id), + ); + self.line(depth, ")"); + self.line(depth, "{"); + + if let Some(samples) = self.context.labware_tracks.get(&node.id) { + let samples = samples.clone(); + self.time_samples(depth + 1, &samples); + } else { + self.line( + depth + 1, + &format!( + "double3 xformOp:translate = ({}, {}, {})", + node.translation[0], node.translation[1], node.translation[2] + ), + ); + } + if node.rotation_z_deg != 0.0 { + self.line( + depth + 1, + &format!("double xformOp:rotateZ = {}", node.rotation_z_deg), + ); + self.line( + depth + 1, + "uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:rotateZ\"]", + ); + } else { + self.line( + depth + 1, + "uniform token[] xformOpOrder = [\"xformOp:translate\"]", + ); + } + + if depth == 0 && self.context.materials { + self.materials_scope(depth + 1); + } + + if let Some(geometry) = &node.geometry { + match geometry { + Geometry::Box { x, y, z } => { + self.fallback_box(depth, &[*x, *y, *z], &node.semantic, &node.id); } - // No USD flavor of this asset: the fallback box, exactly - // as an un-assetted node renders. - None => { - emit_fallback_box(fallback, depth, text); + Geometry::Cylinder { diameter, height } => { + let (diameter, height) = (*diameter, *height); + self.line(depth + 1, "def Cylinder \"geometry\""); + self.line(depth + 1, "{"); + self.binding(depth + 2, &node.semantic, &node.id); + self.line(depth + 2, "uniform token axis = \"Z\""); + self.line(depth + 2, &format!("double height = {height}")); + self.line(depth + 2, &format!("double radius = {}", diameter / 2.0)); + self.line( + depth + 2, + &format!("double3 xformOp:translate = (0, 0, {})", height / 2.0), + ); + self.line( + depth + 2, + "uniform token[] xformOpOrder = [\"xformOp:translate\"]", + ); + self.line(depth + 1, "}"); } - }, + Geometry::Mesh { usd, fallback, .. } => match usd { + // The asset layer carries its own geometry and + // materials; referencing it composes it here. + Some(path) => { + self.line(depth + 1, "def Xform \"geometry\" ("); + self.line(depth + 1, &format!(" prepend references = @{path}@")); + self.line(depth + 1, ")"); + self.line(depth + 1, "{"); + self.line(depth + 1, "}"); + } + // No USD flavor of this asset: the fallback box, + // exactly as an un-assetted node renders. + None => { + self.fallback_box(depth, fallback, &node.semantic, &node.id); + } + }, + } + } + + if let Some(track) = self.context.head_tracks.get(&node.id) { + let track = track.clone(); + self.head_prim(depth, &track); } - } - for child in &node.children { - emit_node(child, depth + 1, text); + for child in &node.children { + self.node(child, depth + 1); + } + self.line(depth, "}"); } - indent(text, depth); - let _ = writeln!(text, "}}"); } #[cfg(test)] @@ -211,5 +403,14 @@ mod tests { text.contains("string labId = \"dna_plate/1:A1\""), "the original id survives as customData" ); + assert!( + text.contains("def Material \"lab_well\"") + && text.contains("rel material:binding = "), + "geometry binds a preview-surface material" + ); + assert!( + !text.contains("endTimeCode"), + "a static layer carries no timeline" + ); } } From 0ecab9c993ea6b624e099137e3df5c398384a739 Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Tue, 11 Aug 2026 22:46:38 -0600 Subject: [PATCH 06/17] Upgrade the trace player to PBR: environment light, shadows, meshes ACES tone mapping, image-based lighting from a procedural room environment, and a shadow-mapped key light replace the flat shading. Mesh geometry loads its glTF asset and swaps out the fallback box, keeping the box on failure. The facility's room shell renders inside-out so the camera sees in from anywhere, nodes honor rotation_z_deg, and the camera frames whatever was built: a bare bench or a whole room. --- viewer/src/main.ts | 97 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 92 insertions(+), 5 deletions(-) diff --git a/viewer/src/main.ts b/viewer/src/main.ts index 4bda77d..e1d13c4 100644 --- a/viewer/src/main.ts +++ b/viewer/src/main.ts @@ -5,17 +5,21 @@ import * as THREE from "three"; import { OrbitControls } from "three/addons/controls/OrbitControls.js"; +import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js"; +import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js"; // ---------- document types ---------- type Geometry = | { shape: "box"; x: number; y: number; z: number } - | { shape: "cylinder"; diameter: number; height: number }; + | { shape: "cylinder"; diameter: number; height: number } + | { shape: "mesh"; gltf?: string; usd?: string; fallback: [number, number, number] }; interface SceneNode { id: string; semantic: { kind: string; [key: string]: unknown }; translation: [number, number, number]; + rotation_z_deg?: number; geometry?: Geometry; children?: SceneNode[]; } @@ -59,10 +63,17 @@ const MATERIALS: Record = { opacity: 0.45, }), station: new THREE.MeshStandardMaterial({ color: 0x9ea3ab, roughness: 0.9 }), + // Walls render inside-out so the camera sees into the room from anywhere. + room: new THREE.MeshStandardMaterial({ + color: 0xd1cfc7, + roughness: 0.95, + side: THREE.BackSide, + }), }; function materialFor(node: SceneNode): THREE.MeshStandardMaterial { const kind = node.semantic.kind; + if (kind === "room") return MATERIALS.room; if (kind === "deck") return MATERIALS.deck; if (kind === "carrier") return MATERIALS.carrier; if (kind === "labware" || kind === "well") { @@ -73,6 +84,30 @@ function materialFor(node: SceneNode): THREE.MeshStandardMaterial { return MATERIALS.station; } +const assetLoader = new GLTFLoader(); + +/** Swaps a fallback box for its real asset once it loads; the box stays + * on failure, which is the registry's contract. */ +function loadAsset(url: string, group: THREE.Group, fallback: THREE.Mesh): void { + assetLoader.load( + url, + (loaded) => { + loaded.scene.traverse((child) => { + if (child instanceof THREE.Mesh) { + child.castShadow = true; + child.receiveShadow = true; + } + }); + group.remove(fallback); + group.add(loaded.scene); + }, + undefined, + () => { + /* keep the fallback box */ + }, + ); +} + const byId = new Map(); const stationMeshes = new Map(); const stationHeights = new Map(); @@ -95,16 +130,25 @@ function buildNode(node: SceneNode): THREE.Object3D { const group = new THREE.Group(); group.name = node.id; group.position.set(...node.translation); + if (node.rotation_z_deg) { + group.rotation.z = (node.rotation_z_deg * Math.PI) / 180; + } byId.set(node.id, group); if (node.geometry) { let mesh: THREE.Mesh; - if (node.geometry.shape === "box") { - const { x, y, z } = node.geometry; + if (node.geometry.shape === "box" || node.geometry.shape === "mesh") { + const [x, y, z] = + node.geometry.shape === "box" + ? [node.geometry.x, node.geometry.y, node.geometry.z] + : node.geometry.fallback; mesh = new THREE.Mesh(new THREE.BoxGeometry(x, y, z), materialFor(node).clone()); // The scene convention puts a box's minimum corner at the node // origin; three centers geometry. mesh.position.set(x / 2, y / 2, z / 2); + if (node.geometry.shape === "mesh" && node.geometry.gltf) { + loadAsset(node.geometry.gltf, group, mesh); + } } else { const { diameter, height } = node.geometry; mesh = new THREE.Mesh( @@ -116,12 +160,20 @@ function buildNode(node: SceneNode): THREE.Object3D { mesh.rotation.x = Math.PI / 2; mesh.position.set(0, 0, height / 2); } + const isWell = node.semantic.kind === "well"; + const isRoom = node.semantic.kind === "room"; + mesh.castShadow = !isWell && !isRoom; + mesh.receiveShadow = true; group.add(mesh); if (node.semantic.kind === "station") { stationMeshes.set(node.id, mesh); stationHeights.set( node.id, - node.geometry.shape === "box" ? node.geometry.z : node.geometry.height, + node.geometry.shape === "box" + ? node.geometry.z + : node.geometry.shape === "cylinder" + ? node.geometry.height + : node.geometry.fallback[2], ); } } @@ -372,10 +424,17 @@ const app = document.getElementById("app")!; const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(window.devicePixelRatio); +renderer.toneMapping = THREE.ACESFilmicToneMapping; +renderer.toneMappingExposure = 1.1; +renderer.shadowMap.enabled = true; +renderer.shadowMap.type = THREE.PCFSoftShadowMap; app.appendChild(renderer.domElement); const three = new THREE.Scene(); three.background = new THREE.Color(0x14161a); +// Image-based lighting from a procedural room: zero external assets. +const pmrem = new THREE.PMREMGenerator(renderer); +three.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture; const camera = new THREE.PerspectiveCamera( 50, window.innerWidth / window.innerHeight, @@ -387,8 +446,16 @@ const controls = new OrbitControls(camera, renderer.domElement); controls.target.set(0.9, 1.0, -0.3); three.add(new THREE.AmbientLight(0xffffff, 0.6)); -const key = new THREE.DirectionalLight(0xffffff, 1.4); +const key = new THREE.DirectionalLight(0xffffff, 1.6); key.position.set(2, 4, 3); +key.castShadow = true; +key.shadow.mapSize.set(2048, 2048); +key.shadow.camera.left = -4; +key.shadow.camera.right = 4; +key.shadow.camera.top = 4; +key.shadow.camera.bottom = -4; +key.shadow.camera.far = 20; +key.shadow.bias = -0.0004; three.add(key); const fill = new THREE.DirectionalLight(0xffffff, 0.5); fill.position.set(-3, 2, -2); @@ -470,12 +537,32 @@ for (const button of document.querySelectorAll("[data-speed]" // ---------- loading ---------- +/** Fits the camera to whatever was just built: benches and whole rooms + * both deserve a establishing shot. */ +function frameScene(): void { + const bounds = new THREE.Box3().setFromObject(labRoot); + if (bounds.isEmpty()) return; + const center = bounds.getCenter(new THREE.Vector3()); + const size = bounds.getSize(new THREE.Vector3()); + const radius = Math.max(size.x, size.y, size.z); + controls.target.copy(center); + camera.position.set( + center.x + radius * 0.55, + center.y + radius * 0.65, + center.z + radius * 0.9, + ); + camera.near = radius / 100; + camera.far = radius * 20; + camera.updateProjectionMatrix(); +} + function tryStart(): void { if (!sceneDoc || !trace) return; labRoot.clear(); byId.clear(); stationMeshes.clear(); labRoot.add(buildNode(sceneDoc.root)); + frameScene(); rememberHomes(); buildTimeline(); applyUpTo(0, false); From 1946e65cd7d76cc1311644d7a01976c77337a593 Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Tue, 11 Aug 2026 22:50:56 -0600 Subject: [PATCH 07/17] Add lab render: a headless Blender harness for the cinematic tier The player script ships inside the binary and reads the same two documents the web player and USD stage consume: it builds Blender objects from scene.json, keyframes labware and pipetting heads from sim-trace.json, lights with the built-in sky or a supplied HDRI, and renders through camera presets with EEVEE (preview) or path-traced Cycles (final). ffmpeg assembles a movie when present; frames stay either way. Blender is found, never bundled, and a missing install is an error naming the fix. The end-to-end check is gated on LAB_BLENDER, the same pattern as the Opentrons simulator tests. --- .gitignore | 1 + crates/lab-cli/src/main.rs | 56 +++ crates/lab-cli/src/render.rs | 154 ++++++++ crates/lab-cli/tests/project_workflow.rs | 75 ++++ docs/integrations/photoreal-assets.md | 47 +++ render/lab_blender.py | 456 +++++++++++++++++++++++ 6 files changed, 789 insertions(+) create mode 100644 crates/lab-cli/src/render.rs create mode 100644 docs/integrations/photoreal-assets.md create mode 100644 render/lab_blender.py diff --git a/.gitignore b/.gitignore index cf53b5b..368b5fc 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ __pycache__/ .ruff_cache/ viewer/node_modules/ viewer/dist/ +.claude/ diff --git a/crates/lab-cli/src/main.rs b/crates/lab-cli/src/main.rs index c13f319..ac62eee 100644 --- a/crates/lab-cli/src/main.rs +++ b/crates/lab-cli/src/main.rs @@ -1,4 +1,5 @@ mod commands; +mod render; mod run; mod scene; mod simulate; @@ -123,6 +124,37 @@ enum Command { #[arg(long)] animated: bool, }, + /// Render the simulated run as photographic frames (and a movie when + /// ffmpeg is present) through a headless Blender. + Render { + /// A run directory holding scene.json and sim-trace.json, from + /// `lab scene` and `lab simulate`. + path: PathBuf, + /// Camera preset. + #[arg(long, default_value = "dolly")] + camera: String, + /// Simulated seconds per wall-clock second of footage. + #[arg(long, default_value_t = 600.0)] + speedup: f64, + /// Frames per second of footage. + #[arg(long, default_value_t = 24)] + fps: u32, + /// `preview` (fast EEVEE) or `final` (path-traced Cycles). + #[arg(long, default_value = "preview")] + quality: String, + /// Render one frame at this simulated second instead of the run. + #[arg(long)] + still: Option, + /// Environment .hdr/.exr for lighting; the built-in sky otherwise. + #[arg(long)] + hdri: Option, + /// The Blender executable; found on PATH or LAB_BLENDER otherwise. + #[arg(long)] + blender: Option, + /// Where to write frames; defaults to `renders/` beside the scene. + #[arg(long)] + out_dir: Option, + }, /// Print resolved package metadata and source-module names. Metadata { /// Package directory or any path inside a package. @@ -212,6 +244,30 @@ fn run() -> Result<()> { facility, animated, } => scene::scene(path, out_dir, facility, animated, &output), + Command::Render { + path, + camera, + speedup, + fps, + quality, + still, + hdri, + blender, + out_dir, + } => render::render( + path, + render::RenderOptions { + camera, + speedup, + fps, + quality, + still, + hdri, + blender, + out_dir, + }, + &output, + ), Command::Metadata { path } => commands::metadata(path, &output), Command::Update { check } => update::update(check, &output), } diff --git a/crates/lab-cli/src/render.rs b/crates/lab-cli/src/render.rs new file mode 100644 index 0000000..f70eb59 --- /dev/null +++ b/crates/lab-cli/src/render.rs @@ -0,0 +1,154 @@ +//! The `lab render` command: the cinematic tier. +//! +//! Blender plays the same two documents the web player and the USD stage +//! consume, headless, and renders frames with Cycles or EEVEE. The player +//! script ships inside this binary and is written next to the output on +//! each run, so `lab render` works wherever the binary does. Blender +//! itself is found, never bundled: a missing installation is a clear +//! error naming the fix, not a build dependency. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{Context, Result, bail}; + +use crate::Output; + +/// The Blender player, embedded at build time. +const PLAYER: &str = include_str!("../../../render/lab_blender.py"); + +pub(crate) struct RenderOptions { + pub camera: String, + pub speedup: f64, + pub fps: u32, + pub quality: String, + pub still: Option, + pub hdri: Option, + pub blender: Option, + pub out_dir: Option, +} + +/// Finds a Blender to run: the flag, the environment, the path, then the +/// standard macOS application bundle. +fn find_blender(flag: Option<&Path>) -> Result { + if let Some(path) = flag { + return Ok(path.to_path_buf()); + } + if let Ok(path) = std::env::var("LAB_BLENDER") { + return Ok(PathBuf::from(path)); + } + if let Ok(output) = Command::new("which").arg("blender").output() + && output.status.success() + { + let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !path.is_empty() { + return Ok(PathBuf::from(path)); + } + } + let bundle = Path::new("/Applications/Blender.app/Contents/MacOS/Blender"); + if bundle.is_file() { + return Ok(bundle.to_path_buf()); + } + bail!( + "no Blender found; install it (macOS: `brew install --cask blender`) or point --blender/LAB_BLENDER at the executable" + ); +} + +pub(crate) fn render(directory: PathBuf, options: RenderOptions, output: &Output) -> Result<()> { + let scene_path = directory.join("scene.json"); + if !scene_path.is_file() { + bail!( + "no scene at {}; run `lab scene` on this package first", + scene_path.display() + ); + } + let trace_path = directory.join("sim-trace.json"); + if !trace_path.is_file() { + bail!( + "no trace at {}; run `lab simulate` on this package first", + trace_path.display() + ); + } + let blender = find_blender(options.blender.as_deref())?; + let out_dir = options.out_dir.unwrap_or_else(|| directory.join("renders")); + std::fs::create_dir_all(&out_dir) + .with_context(|| format!("failed to create {}", out_dir.display()))?; + + let script_path = out_dir.join("lab_blender.py"); + std::fs::write(&script_path, PLAYER) + .with_context(|| format!("failed to write {}", script_path.display()))?; + + let mut command = Command::new(&blender); + command + .arg("--background") + .arg("--factory-startup") + .arg("--python") + .arg(&script_path) + .arg("--") + .arg("--scene") + .arg(&scene_path) + .arg("--trace") + .arg(&trace_path) + .arg("--out") + .arg(&out_dir) + .arg("--camera") + .arg(&options.camera) + .arg("--speedup") + .arg(options.speedup.to_string()) + .arg("--fps") + .arg(options.fps.to_string()) + .arg("--quality") + .arg(&options.quality); + if let Some(still) = options.still { + command.arg("--still").arg(still.to_string()); + } + if let Some(hdri) = &options.hdri { + command.arg("--hdri").arg(hdri); + } + + println!("rendering with {}", blender.display()); + let status = command + .status() + .with_context(|| format!("failed to run {}", blender.display()))?; + if !status.success() { + bail!("Blender exited with {status}"); + } + + // Assemble a movie when ffmpeg is around; the frames stay either way. + let mut movie = None; + if options.still.is_none() + && Command::new("ffmpeg") + .arg("-version") + .output() + .is_ok_and(|probe| probe.status.success()) + { + let movie_path = out_dir.join("run.mp4"); + let assembled = Command::new("ffmpeg") + .args(["-y", "-framerate", &options.fps.to_string(), "-i"]) + .arg(out_dir.join("frames/%04d.png")) + .args(["-pix_fmt", "yuv420p"]) + .arg(&movie_path) + .status() + .map(|status| status.success()) + .unwrap_or(false); + if assembled { + movie = Some(movie_path); + } + } + + output.success( + "render", + serde_json::json!({ + "out": out_dir.display().to_string(), + "movie": movie.as_ref().map(|path| path.display().to_string()), + }), + format!( + "rendered under {}{}", + out_dir.display(), + match &movie { + Some(path) => format!("\nmovie: {}", path.display()), + None => String::new(), + } + ), + ) +} diff --git a/crates/lab-cli/tests/project_workflow.rs b/crates/lab-cli/tests/project_workflow.rs index c45cbb8..760ad69 100644 --- a/crates/lab-cli/tests/project_workflow.rs +++ b/crates/lab-cli/tests/project_workflow.rs @@ -923,3 +923,78 @@ fn an_animated_scene_plays_the_simulated_run_on_the_usd_timeline() { std::fs::remove_dir_all(&out_dir).unwrap(); } + +/// Gated on a local Blender, the same pattern as the Opentrons simulator +/// checks: `LAB_BLENDER=/path/to/blender cargo test -p lab-cli`. +#[test] +fn a_wave_renders_one_photographic_frame_through_blender() { + let Ok(blender) = std::env::var("LAB_BLENDER") else { + eprintln!("skipping: set LAB_BLENDER to run the render check"); + return; + }; + let example = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../examples/golden-gate") + .canonicalize() + .unwrap(); + let out_dir = std::env::temp_dir().join(format!( + "lab-golden-gate-render-{}-{}", + std::process::id(), + line!() + )); + if out_dir.exists() { + std::fs::remove_dir_all(&out_dir).unwrap(); + } + + for args in [ + vec![ + "build", + example.to_str().unwrap(), + "--target", + "workcell-star", + "--out-dir", + out_dir.to_str().unwrap(), + ], + vec![ + "simulate", + out_dir.join("workcell-star/wave-001").to_str().unwrap(), + ], + vec![ + "scene", + out_dir.join("workcell-star/wave-001").to_str().unwrap(), + ], + ] { + let step = Command::new(env!("CARGO_BIN_EXE_lab")) + .args(&args) + .output() + .unwrap(); + assert!(step.status.success(), "step {args:?} failed"); + } + + let wave = out_dir.join("workcell-star/wave-001"); + let rendered = Command::new(env!("CARGO_BIN_EXE_lab")) + .args([ + "render", + wave.to_str().unwrap(), + "--still", + "600", + "--quality", + "preview", + "--blender", + &blender, + ]) + .output() + .unwrap(); + assert!( + rendered.status.success(), + "render failed: {}", + String::from_utf8_lossy(&rendered.stderr) + ); + let still = wave.join("renders/frames/still.png"); + assert!(still.is_file(), "the still frame exists"); + assert!( + std::fs::metadata(&still).unwrap().len() > 10_000, + "the frame is a real image, not an empty file" + ); + + std::fs::remove_dir_all(&out_dir).unwrap(); +} diff --git a/docs/integrations/photoreal-assets.md b/docs/integrations/photoreal-assets.md new file mode 100644 index 0000000..6f4342d --- /dev/null +++ b/docs/integrations/photoreal-assets.md @@ -0,0 +1,47 @@ +# Preparing photoreal assets + +A facility's `assets/` directory turns schematic boxes into real +instruments. Asset preparation is a human workflow, not a build step: +vendor CAD is licensed to you, not to this repository, so assets live +beside your facility file and never in version control here. + +## The recipe + +1. **Get the CAD.** Vendors supply STEP/IGES integration models on + request (Hamilton, Inheco, and peers all do this for workcell + integrators). Opentrons publishes OT-2 hardware files openly. +2. **Import and clean.** FreeCAD or Blender imports STEP. Delete + internals, decimate to a sensible polygon budget (an instrument body + needs tens of thousands of triangles, not millions), and join loose + shells. +3. **Material it.** Assign PBR materials (anodized aluminum, powder + coat, polycarbonate). Texture painting is optional; measured material + values carry most of the realism. +4. **Export both flavors** into the facility's `assets/` directory: + - `.glb` for the web player and the Blender harness; + - `.usd` for Omniverse, Isaac Sim, and usdview. + +## Conventions + +- **Keys** are the identities the scene speaks: station kind strings + (`hamilton.star.glb`, `inheco.odtc.usd`), labware catalog ids + (`pcr_plate_96.glb`), carrier catalog ids, and `room` for a modeled or + scanned environment. +- **Units are millimeters** in both formats. USD layers declare + `metersPerUnit = 0.001`; glTF is nominally meters, so export with the + scene's numeric values unchanged (1 unit = 1 mm) — the players scale + the whole lab frame once. +- **Origin at the node anchor**: stations at floor-center of their + footprint's front-left, labware at the footprint's minimum corner. + +X right, +Y toward the back, Z up. +- **Fallback always works.** A missing or failing asset renders as the + dimensioned box; nothing ever blocks on an asset. + +## Rendering tiers + +- `lab scene --facility …` bundles referenced assets beside the scene. +- The web player and `lab render` (Blender) load the `.glb` flavor. +- Omniverse and Isaac Sim open `scene.usda`, which composes the `.usd` + flavor by reference; `lab scene --animated` adds the run's timeline. +- `lab render --quality final` path-traces with Cycles; pass `--hdri` + for measured environment lighting, or use the built-in sky. diff --git a/render/lab_blender.py b/render/lab_blender.py new file mode 100644 index 0000000..a58f4e2 --- /dev/null +++ b/render/lab_blender.py @@ -0,0 +1,456 @@ +# The Blender player: a third interpreter of the same two documents the +# web player and the USD stage consume. It reads scene.json and +# sim-trace.json, builds Blender objects, keyframes motion from trace +# events, and renders with Cycles (final) or EEVEE (preview). It computes +# nothing about the run itself: every state change comes from an event. +# +# Run headless through Blender's bundled Python; bpy and stdlib only: +# +# blender --background --factory-startup \ +# --python render/lab_blender.py -- \ +# --scene scene.json --trace sim-trace.json --out renders \ +# --camera dolly --speedup 600 --fps 24 --quality preview + +import argparse +import json +import math +import os +import sys + +import bpy +import mathutils + +# ---------------------------------------------------------------- documents + + +def parse_args(): + argv = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else [] + parser = argparse.ArgumentParser(prog="lab_blender") + parser.add_argument("--scene", required=True) + parser.add_argument("--trace", required=True) + parser.add_argument("--out", required=True) + parser.add_argument("--camera", default="dolly", choices=["orbit", "dolly"]) + parser.add_argument("--speedup", type=float, default=600.0) + parser.add_argument("--fps", type=int, default=24) + parser.add_argument("--quality", default="preview", choices=["preview", "final"]) + parser.add_argument("--from-t", dest="from_t", type=float, default=0.0) + parser.add_argument("--to-t", dest="to_t", type=float, default=None) + parser.add_argument("--still", type=float, default=None, + help="render one frame at this simulated second") + parser.add_argument("--hdri", default=None, + help="environment .hdr/.exr; the built-in sky otherwise") + return parser.parse_args(argv) + + +def load_documents(args): + with open(args.scene, encoding="utf-8") as handle: + scene = json.load(handle) + with open(args.trace, encoding="utf-8") as handle: + trace = json.load(handle) + if scene.get("format") != "lab.scene.v0": + raise SystemExit(f"{args.scene} is not a lab.scene.v0 document") + if trace.get("format") != "lab.sim-trace.v0": + raise SystemExit(f"{args.trace} is not a lab.sim-trace.v0 document") + return scene, trace + + +# ---------------------------------------------------------------- materials + +# name -> (rgb, roughness, metallic, alpha) +MATERIALS = { + "lab.deck": ((0.35, 0.36, 0.38), 0.55, 0.6, 1.0), + "lab.carrier": ((0.16, 0.17, 0.20), 0.4, 0.9, 1.0), + "lab.plate": ((0.86, 0.86, 0.88), 0.35, 0.0, 1.0), + "lab.tips": ((0.75, 0.35, 0.06), 0.45, 0.0, 1.0), + "lab.well": ((0.12, 0.30, 0.75), 0.15, 0.0, 0.35), + "lab.station": ((0.55, 0.57, 0.60), 0.5, 0.4, 1.0), + "lab.room": ((0.75, 0.74, 0.71), 0.9, 0.0, 1.0), + "lab.head": ((0.08, 0.08, 0.10), 0.35, 0.6, 1.0), +} + + +def build_materials(): + built = {} + for name, (rgb, roughness, metallic, alpha) in MATERIALS.items(): + material = bpy.data.materials.new(name) + material.use_nodes = True + bsdf = material.node_tree.nodes["Principled BSDF"] + bsdf.inputs["Base Color"].default_value = (*rgb, 1.0) + bsdf.inputs["Roughness"].default_value = roughness + bsdf.inputs["Metallic"].default_value = metallic + bsdf.inputs["Alpha"].default_value = alpha + if alpha < 1.0: + material.blend_method = "BLEND" + built[name] = material + return built + + +def material_for(node, materials): + kind = node.get("semantic", {}).get("kind", "") + catalog = str(node.get("semantic", {}).get("catalog", "")) + node.get("id", "") + if kind == "room": + return materials["lab.room"] + if kind == "deck": + return materials["lab.deck"] + if kind == "carrier": + return materials["lab.carrier"] + if kind in ("labware", "well") and "tip" in catalog: + return materials["lab.tips"] + if kind == "well": + return materials["lab.well"] + if kind == "labware": + return materials["lab.plate"] + return materials["lab.station"] + + +# ---------------------------------------------------------------- geometry + + +def unit_box_mesh(): + mesh = bpy.data.meshes.get("lab-unit-box") + if mesh: + return mesh + mesh = bpy.data.meshes.new("lab-unit-box") + verts = [(x, y, z) for z in (0, 1) for y in (0, 1) for x in (0, 1)] + faces = [ + (0, 2, 3, 1), (4, 5, 7, 6), (0, 1, 5, 4), + (2, 6, 7, 3), (0, 4, 6, 2), (1, 3, 7, 5), + ] + mesh.from_pydata(verts, [], faces) + mesh.update() + return mesh + + +def unit_cylinder_mesh(segments=24): + mesh = bpy.data.meshes.get("lab-unit-cylinder") + if mesh: + return mesh + mesh = bpy.data.meshes.new("lab-unit-cylinder") + verts, faces = [], [] + for z in (0.0, 1.0): + for segment in range(segments): + angle = math.tau * segment / segments + verts.append((0.5 * math.cos(angle), 0.5 * math.sin(angle), z)) + for segment in range(segments): + following = (segment + 1) % segments + faces.append((segment, following, segments + following, segments + segment)) + faces.append(tuple(range(segments - 1, -1, -1))) + faces.append(tuple(range(segments, 2 * segments))) + mesh.from_pydata(verts, [], faces) + mesh.update() + for polygon in mesh.polygons: + polygon.use_smooth = True + return mesh + + +def link(obj, parent): + bpy.context.scene.collection.objects.link(obj) + if parent is not None: + obj.parent = parent + return obj + + +def assign_material(obj, material): + """Object-level material over a shared mesh: the mesh contributes one + empty slot, each object overrides it.""" + if not obj.data.materials: + obj.data.materials.append(None) + obj.material_slots[0].link = "OBJECT" + obj.material_slots[0].material = material + + +class Builder: + """Builds the Blender object tree from scene.json.""" + + def __init__(self, materials, scene_dir): + self.materials = materials + self.scene_dir = scene_dir + self.by_id = {} + self.station_heights = {} + self.bounds_min = [math.inf] * 3 + self.bounds_max = [-math.inf] * 3 + + def grow_bounds(self, origin, extent): + for axis in range(3): + self.bounds_min[axis] = min(self.bounds_min[axis], origin[axis]) + self.bounds_max[axis] = max(self.bounds_max[axis], origin[axis] + extent[axis]) + + def geometry_object(self, node, geometry, parent): + shape = geometry.get("shape") + material = material_for(node, self.materials) + if shape == "mesh" and geometry.get("gltf"): + path = os.path.join(self.scene_dir, geometry["gltf"]) + if os.path.isfile(path): + before = set(bpy.data.objects) + try: + bpy.ops.import_scene.gltf(filepath=path) + for imported in set(bpy.data.objects) - before: + if imported.parent is None: + imported.parent = parent + return + except Exception as error: # noqa: BLE001 - fall back to the box + print(f"asset {path} failed to import ({error}); using fallback") + if shape == "cylinder": + obj = bpy.data.objects.new(node["id"] + "#geometry", unit_cylinder_mesh()) + obj.scale = (geometry["diameter"], geometry["diameter"], geometry["height"]) + else: + extent = ( + (geometry["x"], geometry["y"], geometry["z"]) + if shape == "box" + else tuple(geometry["fallback"]) + ) + obj = bpy.data.objects.new(node["id"] + "#geometry", unit_box_mesh()) + obj.scale = extent + link(obj, parent) + assign_material(obj, material) + + def node(self, node, parent, origin): + group = bpy.data.objects.new(node["id"], None) + group.location = tuple(node.get("translation", [0, 0, 0])) + group.rotation_euler = (0, 0, math.radians(node.get("rotation_z_deg", 0.0))) + link(group, parent) + self.by_id[node["id"]] = group + + here = [origin[axis] + group.location[axis] for axis in range(3)] + geometry = node.get("geometry") + if geometry: + extent = { + "box": lambda g: (g["x"], g["y"], g["z"]), + "cylinder": lambda g: (g["diameter"], g["diameter"], g["height"]), + "mesh": lambda g: tuple(g["fallback"]), + }[geometry["shape"]](geometry) + self.grow_bounds(here, extent) + if node.get("semantic", {}).get("kind") == "station": + self.station_heights[node["id"]] = extent[2] + self.geometry_object(node, geometry, group) + for child in node.get("children", []): + self.node(child, group, here) + + +# ---------------------------------------------------------------- animation + + +def world_origin(obj): + matrix = obj.matrix_world + return (matrix[0][3], matrix[1][3], matrix[2][3]) + + +class Animator: + """Keyframes trace events onto the built objects.""" + + def __init__(self, builder, materials, fps, speedup, from_t): + self.builder = builder + self.materials = materials + self.fps = fps + self.speedup = speedup + self.from_t = from_t + self.heads = {} + + def frame(self, t): + return 1 + max(0.0, t - self.from_t) / self.speedup * self.fps + + def key_location(self, obj, t, interpolation="LINEAR"): + obj.keyframe_insert(data_path="location", frame=self.frame(t)) + action = obj.animation_data.action + for fcurve in action.fcurves: + fcurve.keyframe_points[-1].interpolation = interpolation + + def head_for(self, station): + if station in self.heads: + return self.heads[station] + parent = self.builder.by_id.get(station) + head = bpy.data.objects.new(f"{station}:head", None) + link(head, parent) + carriage = bpy.data.objects.new(f"{station}:head#carriage", unit_box_mesh()) + carriage.scale = (60, 60, 110) + carriage.location = (-30, -30, 0) + link(carriage, head) + assign_material(carriage, self.materials["lab.head"]) + tip = bpy.data.objects.new(f"{station}:head#tip", unit_cylinder_mesh()) + tip.scale = (5, 5, 70) + tip.location = (0, 0, -70) + link(tip, head) + assign_material(tip, self.materials["lab.plate"]) + head.hide_render = True + head.hide_viewport = True + head.keyframe_insert(data_path="hide_render", frame=1) + head.keyframe_insert(data_path="hide_viewport", frame=1) + self.heads[station] = head + return head + + def set_head_visible(self, station, t, visible): + head = self.head_for(station) + head.hide_render = not visible + head.hide_viewport = not visible + head.keyframe_insert(data_path="hide_render", frame=self.frame(t)) + head.keyframe_insert(data_path="hide_viewport", frame=self.frame(t)) + + def play(self, trace): + visible = {} + for timed in trace["events"]: + t = timed["t"] + event = timed["event"] + if event == "frame" and timed.get("x_mm") is not None: + station = timed["station"] + head = self.head_for(station) + if not visible.get(station): + self.set_head_visible(station, t, True) + visible[station] = True + head.location = (timed["x_mm"], timed["y_mm"], 260) + self.key_location(head, t) + else: + head.location = (timed["x_mm"], timed["y_mm"], 260) + self.key_location(head, t) + elif event == "node-completed": + for station, is_visible in list(visible.items()): + if is_visible: + self.set_head_visible(station, t, False) + visible[station] = False + elif event == "labware-moved": + mover = self.builder.by_id.get(timed["labware"]) + station = self.builder.by_id.get(timed["to"]) + if mover is None or station is None or mover.parent is None: + continue + self.key_location(mover, t, interpolation="CONSTANT") + bpy.context.view_layer.update() + seat = list(world_origin(station)) + seat[2] += self.builder.station_heights.get(timed["to"], 0.0) + 5.0 + inverse = mover.parent.matrix_world.inverted() + local = inverse @ mathutils.Vector(seat) + mover.location = tuple(local) + self.key_location(mover, t + 2.0) + + +# ---------------------------------------------------------------- shooting + + +def build_world(hdri): + world = bpy.data.worlds.new("lab-world") + world.use_nodes = True + nodes = world.node_tree.nodes + background = nodes["Background"] + if hdri and os.path.isfile(hdri): + environment = nodes.new("ShaderNodeTexEnvironment") + environment.image = bpy.data.images.load(hdri) + world.node_tree.links.new( + environment.outputs["Color"], background.inputs["Color"] + ) + background.inputs["Strength"].default_value = 1.0 + else: + sky = nodes.new("ShaderNodeTexSky") + sky.sun_elevation = math.radians(35.0) + world.node_tree.links.new(sky.outputs["Color"], background.inputs["Color"]) + background.inputs["Strength"].default_value = 0.35 + bpy.context.scene.world = world + + +def build_camera(preset, bounds_min, bounds_max, frame_end, fps): + center = [(bounds_min[i] + bounds_max[i]) / 2.0 for i in range(3)] + size = max(bounds_max[i] - bounds_min[i] for i in range(3)) + target = bpy.data.objects.new("lab-camera-target", None) + target.location = (center[0], center[1], max(900.0, center[2])) + bpy.context.scene.collection.objects.link(target) + + camera_data = bpy.data.cameras.new("lab-camera") + camera_data.lens = 35 + camera_data.dof.use_dof = True + camera_data.dof.focus_object = target + camera_data.dof.aperture_fstop = 2.8 + camera = bpy.data.objects.new("lab-camera", camera_data) + bpy.context.scene.collection.objects.link(camera) + bpy.context.scene.camera = camera + constraint = camera.constraints.new("TRACK_TO") + constraint.target = target + + if preset == "orbit": + radius = size * 0.9 + steps = 32 + for step in range(steps + 1): + angle = math.tau * step / steps + camera.location = ( + center[0] + radius * math.cos(angle), + center[1] + radius * math.sin(angle), + center[2] + size * 0.5, + ) + camera.keyframe_insert( + data_path="location", frame=1 + step * (frame_end - 1) / steps + ) + for fcurve in camera.animation_data.action.fcurves: + for point in fcurve.keyframe_points: + point.interpolation = "LINEAR" + else: # dolly + camera.location = ( + bounds_min[0] - size * 0.15, + bounds_min[1] - size * 0.55, + center[2] + size * 0.35, + ) + camera.keyframe_insert(data_path="location", frame=1) + camera.location = ( + bounds_max[0] + size * 0.15, + bounds_min[1] - size * 0.55, + center[2] + size * 0.35, + ) + camera.keyframe_insert(data_path="location", frame=frame_end) + for fcurve in camera.animation_data.action.fcurves: + for point in fcurve.keyframe_points: + point.interpolation = "LINEAR" + + +def configure_render(args, out_dir): + scene = bpy.context.scene + scene.render.resolution_x = 1920 + scene.render.resolution_y = 1080 + scene.render.fps = args.fps + scene.render.image_settings.file_format = "PNG" + scene.render.filepath = os.path.join(out_dir, "frames", "") + if args.quality == "final": + scene.render.engine = "CYCLES" + scene.cycles.samples = 512 + scene.cycles.use_denoising = True + else: + scene.render.engine = "BLENDER_EEVEE_NEXT" + scene.eevee.taa_render_samples = 64 + + +def main(): + args = parse_args() + document, trace = load_documents(args) + os.makedirs(os.path.join(args.out, "frames"), exist_ok=True) + + # A clean stage: factory startup still ships a cube, camera, and light. + for obj in list(bpy.data.objects): + bpy.data.objects.remove(obj, do_unlink=True) + bpy.context.scene.unit_settings.system = "METRIC" + bpy.context.scene.unit_settings.scale_length = 0.001 + + materials = build_materials() + builder = Builder(materials, os.path.dirname(os.path.abspath(args.scene))) + builder.node(document["root"], None, [0.0, 0.0, 0.0]) + bpy.context.view_layer.update() + + total = trace.get("summary", {}).get("total_seconds", 0.0) + to_t = args.to_t if args.to_t is not None else total + animator = Animator(builder, materials, args.fps, args.speedup, args.from_t) + animator.play(trace) + + frame_end = max(2, math.ceil((to_t - args.from_t) / args.speedup * args.fps)) + bpy.context.scene.frame_start = 1 + bpy.context.scene.frame_end = frame_end + + build_world(args.hdri) + build_camera(args.camera, builder.bounds_min, builder.bounds_max, frame_end, args.fps) + configure_render(args, args.out) + + if args.still is not None: + frame = int(animator.frame(args.still)) + bpy.context.scene.frame_set(min(max(1, frame), frame_end)) + bpy.context.scene.render.filepath = os.path.join(args.out, "frames", "still") + bpy.ops.render.render(write_still=True) + print(f"lab render: wrote {bpy.context.scene.render.filepath}.png") + else: + bpy.ops.render.render(animation=True) + print(f"lab render: wrote {frame_end} frame(s) under {args.out}/frames") + + +if __name__ == "__main__": + main() From 0b46421fcddabc0757abfe04ac29d927eb0fa3ee Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Tue, 11 Aug 2026 22:53:18 -0600 Subject: [PATCH 08/17] Record decision 0036: photoreal renderers are players Every renderer interprets the same scene and trace documents: assets are facility-owned references with box fallbacks, the facility authors the room, USD carries the animation, and the schematic tier stays first-class. Scanned environments compose in later as the room asset without changing any format. --- docs/README.md | 1 + .../decisions/0036-photoreal-projections.md | 53 +++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 docs/language/decisions/0036-photoreal-projections.md diff --git a/docs/README.md b/docs/README.md index 0128f67..bb9e1ab 100644 --- a/docs/README.md +++ b/docs/README.md @@ -63,6 +63,7 @@ Decision records preserve the reasoning and status behind the language rather th | [0033: Typeset protocol documents](language/decisions/0033-typeset-protocol-documents.md) | protocol documents are typeset PDFs emitted beside their sources | | [0034: The simulator is an interpreter](language/decisions/0034-the-simulator-is-an-interpreter.md) | `lab simulate` interprets the same run documents `lab run` executes; the trace is the visualization contract | | [0035: Facility files](language/decisions/0035-facility-files.md) | a facility is its own file under `facilities/`; the manifest carries at most a pointer | +| [0036: Photoreal projections](language/decisions/0036-photoreal-projections.md) | renderers are players of the scene and trace; assets are facility-owned references with box fallbacks | ## Implementation and embedding diff --git a/docs/language/decisions/0036-photoreal-projections.md b/docs/language/decisions/0036-photoreal-projections.md new file mode 100644 index 0000000..8b8fb00 --- /dev/null +++ b/docs/language/decisions/0036-photoreal-projections.md @@ -0,0 +1,53 @@ +# 0036 — Photoreal renderers are players of the scene and trace + +## Status + +Accepted. + +## Context + +The simulation's first renderer drew schematic boxes, which serves the +scientist checking a run but not the designer of a new facility, who +needs to see the lab that does not exist yet, nor the robot-learning +work that needs scan-accurate environments. Photorealism could have been +built as its own pipeline with its own model of the experiment; that +shape invites the drift 0034 exists to prevent. + +## Decision + +Every renderer is a player of the same two documents, `scene.json` and +`sim-trace.json`, and computes nothing about the run: the web player for +daily use, the USD stage for Omniverse and Isaac Sim, and a headless +Blender harness (`lab render`) for batch photographic output. Realism is +layered onto the documents, never forked from them: + +- **Assets are references, never embedded.** A facility's `assets/` + directory maps identity keys (station kinds, labware catalog ids, + `room`) to real meshes; `lab scene` bundles referenced files beside + the scene and every consumer falls back to the dimensioned box when an + asset is missing or fails. Vendor CAD is licensed to the facility's + owner, so assets live with the facility and never in this repository. +- **The facility authors the space.** `[room]` and per-station + positions and rotations in `facility.toml` are the floor plan; the kit + room renders when no environment asset exists. +- **USD carries the animation.** One timecode per simulated second, + written by `lab scene --animated`; Omniverse and usdview play the run + with no integration code on either side. +- **The schematic tier stays first-class.** Without a facility, every + command renders exactly the schematic scene; photorealism is a + projection, not a replacement. + +Environments may later be scans: a Gaussian-splat or mesh capture of a +real room composes with authored stations as the `room` environment +asset. That layer is also where robot-learning environments attach; it +changes no document format. + +## Consequences + +A biofoundry design review is `facility.toml` plus three commands, and +its fidelity grows file by file as assets arrive, with nothing blocking +on any of them. The renderers cannot disagree with the simulation or +with each other about what happened, only about how it looks. The cost +is honest too: the movie tier depends on a local Blender the toolchain +finds but never bundles, and asset preparation is a documented human +workflow (docs/integrations/photoreal-assets.md), not a build step. From 0b56519d4f9d66840cea8fcccf66e1b366dfc533 Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Wed, 12 Aug 2026 00:21:19 -0600 Subject: [PATCH 09/17] Model instruments procedurally and prove the Blender tier Stations now carry cosmetic part assemblies built from the same primitives every renderer draws: the STAR gets its towers, gantry beam, back panel, front glass, and status light; the cycler its base, lid seam, vents, and light; and every station stands on a bench. Parts are children of the station node with a material vocabulary (frame, panel, glass, accent) honored by the web player, glTF, USD, and Blender; the station's own geometry stays the body the seat math measures. Running the harness against a real Blender 5.2 fixed what only a real Blender could: keyframe interpolation rides the new-keyframe preference (the fcurve API changed shape across 4/5), the EEVEE identifier is probed across generations, camera clip ranges suit millimeter scenes, script failures now fail the process (--python-exit-code), and the room shell renders without driving the camera framing. Facility and run-document errors no longer print their cause twice. --- crates/lab-cli/src/render.rs | 2 + crates/lab-runfmt/src/facility.rs | 4 +- crates/lab-runfmt/src/lib.rs | 4 +- crates/lab-scene/src/gltf.rs | 13 ++- crates/lab-scene/src/instruments.rs | 161 ++++++++++++++++++++++++++++ crates/lab-scene/src/lib.rs | 1 + crates/lab-scene/src/scene.rs | 4 + crates/lab-scene/src/usda.rs | 13 ++- crates/lab-scene/src/workcell.rs | 11 ++ render/lab_blender.py | 40 +++++-- viewer/src/main.ts | 16 ++- 11 files changed, 251 insertions(+), 18 deletions(-) create mode 100644 crates/lab-scene/src/instruments.rs diff --git a/crates/lab-cli/src/render.rs b/crates/lab-cli/src/render.rs index f70eb59..fe30a78 100644 --- a/crates/lab-cli/src/render.rs +++ b/crates/lab-cli/src/render.rs @@ -82,6 +82,8 @@ pub(crate) fn render(directory: PathBuf, options: RenderOptions, output: &Output command .arg("--background") .arg("--factory-startup") + .arg("--python-exit-code") + .arg("1") .arg("--python") .arg(&script_path) .arg("--") diff --git a/crates/lab-runfmt/src/facility.rs b/crates/lab-runfmt/src/facility.rs index 6b43e3c..64ea414 100644 --- a/crates/lab-runfmt/src/facility.rs +++ b/crates/lab-runfmt/src/facility.rs @@ -18,13 +18,13 @@ pub const FACILITY_DIR: &str = "facilities"; #[derive(Debug, thiserror::Error)] pub enum FacilityError { - #[error("cannot read {path}: {source}")] + #[error("cannot read {path}")] Io { path: String, #[source] source: std::io::Error, }, - #[error("{path} is not a valid facility description: {source}")] + #[error("{path} is not a valid facility description")] Parse { path: String, #[source] diff --git a/crates/lab-runfmt/src/lib.rs b/crates/lab-runfmt/src/lib.rs index 29fd880..7db0020 100644 --- a/crates/lab-runfmt/src/lib.rs +++ b/crates/lab-runfmt/src/lib.rs @@ -40,13 +40,13 @@ pub const WORKCELL_PLAN_FILE: &str = "plan.workcell.json"; /// Why a run document failed to load. #[derive(Debug, thiserror::Error)] pub enum RunDocumentError { - #[error("cannot read {path}: {source}")] + #[error("cannot read {path}")] Io { path: String, #[source] source: std::io::Error, }, - #[error("{path} is not a valid document: {source}")] + #[error("{path} is not a valid document")] Parse { path: String, #[source] diff --git a/crates/lab-scene/src/gltf.rs b/crates/lab-scene/src/gltf.rs index aff20f2..c642ead 100644 --- a/crates/lab-scene/src/gltf.rs +++ b/crates/lab-scene/src/gltf.rs @@ -115,7 +115,11 @@ fn materials() -> Value { { "name": "plate", "pbrMetallicRoughness": { "baseColorFactor": [0.92, 0.92, 0.94, 1.0], "roughnessFactor": 0.5 }, "doubleSided": true }, { "name": "tips", "pbrMetallicRoughness": { "baseColorFactor": [0.90, 0.60, 0.20, 1.0], "roughnessFactor": 0.6 }, "doubleSided": true }, { "name": "well", "pbrMetallicRoughness": { "baseColorFactor": [0.30, 0.55, 0.90, 0.45], "roughnessFactor": 0.3 }, "alphaMode": "BLEND", "doubleSided": true }, - { "name": "station", "pbrMetallicRoughness": { "baseColorFactor": [0.62, 0.64, 0.68, 1.0], "roughnessFactor": 0.9 }, "doubleSided": true } + { "name": "station", "pbrMetallicRoughness": { "baseColorFactor": [0.62, 0.64, 0.68, 1.0], "roughnessFactor": 0.9 }, "doubleSided": true }, + { "name": "frame", "pbrMetallicRoughness": { "baseColorFactor": [0.16, 0.17, 0.20, 1.0], "roughnessFactor": 0.45, "metallicFactor": 0.8 }, "doubleSided": true }, + { "name": "panel", "pbrMetallicRoughness": { "baseColorFactor": [0.80, 0.81, 0.83, 1.0], "roughnessFactor": 0.55, "metallicFactor": 0.3 }, "doubleSided": true }, + { "name": "glass", "pbrMetallicRoughness": { "baseColorFactor": [0.65, 0.75, 0.82, 0.22], "roughnessFactor": 0.05 }, "alphaMode": "BLEND", "doubleSided": true }, + { "name": "accent", "pbrMetallicRoughness": { "baseColorFactor": [0.10, 0.55, 0.75, 1.0], "roughnessFactor": 0.3 }, "doubleSided": true } ]) } @@ -131,6 +135,13 @@ fn material_index(semantic: &Semantic, id: &str) -> usize { Semantic::Well { .. } if id.contains("tip") => 3, Semantic::Well { .. } => 4, Semantic::Room | Semantic::Station { .. } | Semantic::Site { .. } => 5, + Semantic::Part { material } => match material.as_str() { + "frame" => 6, + "panel" => 7, + "glass" => 8, + "accent" => 9, + _ => 5, + }, } } diff --git a/crates/lab-scene/src/instruments.rs b/crates/lab-scene/src/instruments.rs new file mode 100644 index 0000000..45d0667 --- /dev/null +++ b/crates/lab-scene/src/instruments.rs @@ -0,0 +1,161 @@ +//! Procedurally modeled instruments: the middle tier of the asset +//! registry, between a vendor mesh and the bare box. +//! +//! Each assembly is built from the same primitives every renderer draws, +//! so a facility with no asset files still reads as a lab: the STAR has +//! its towers, gantry, and glass hood; the cycler has its lid and status +//! light; every station stands on a bench. Parts are cosmetic children of +//! the station node — the station's own geometry stays the simple body the +//! seat and animation math measure against. + +use crate::scene::{Geometry, SceneNode, Semantic}; + +fn part(id: String, material: &str, translation: [f64; 3], extent: [f64; 3]) -> SceneNode { + SceneNode::new( + id, + Semantic::Part { + material: material.to_string(), + }, + translation, + ) + .with_geometry(Geometry::Box { + x: extent[0], + y: extent[1], + z: extent[2], + }) +} + +/// A bench under a station: worktop and four legs, sized to the footprint +/// with an overhang. The station origin stays at the bench top. +fn bench(station: &str, footprint: [f64; 2]) -> Vec { + let width = footprint[0].max(500.0) + 200.0; + let depth = footprint[1].max(400.0) + 150.0; + let x0 = (footprint[0] - width) / 2.0; + let y0 = (footprint[1] - depth) / 2.0; + let mut parts = vec![part( + format!("{station}:bench:top"), + "panel", + [x0, y0, -40.0], + [width, depth, 40.0], + )]; + let leg = 60.0; + for (index, (leg_x, leg_y)) in [ + (x0 + 20.0, y0 + 20.0), + (x0 + width - leg - 20.0, y0 + 20.0), + (x0 + 20.0, y0 + depth - leg - 20.0), + (x0 + width - leg - 20.0, y0 + depth - leg - 20.0), + ] + .into_iter() + .enumerate() + { + parts.push(part( + format!("{station}:bench:leg-{}", index + 1), + "frame", + [leg_x, leg_y, -860.0], + [leg, leg, 820.0], + )); + } + parts +} + +/// The Hamilton STAR body around its deck: side towers, the gantry beam +/// the pipetting head rides, a back panel, and the front glass. +fn star_assembly(station: &str) -> Vec { + let mut parts = bench(station, [1400.0, 700.0]); + let tower = |id: &str, x: f64| { + part( + format!("{station}:{id}"), + "frame", + [x, 0.0, 60.0], + [90.0, 700.0, 560.0], + ) + }; + parts.push(tower("tower-left", -90.0)); + parts.push(tower("tower-right", 1400.0)); + // The gantry beam spans the towers over the deck's working area. + parts.push(part( + format!("{station}:gantry"), + "frame", + [-90.0, 240.0, 620.0], + [1580.0, 220.0, 110.0], + )); + parts.push(part( + format!("{station}:back-panel"), + "panel", + [-90.0, 690.0, 60.0], + [1580.0, 60.0, 670.0], + )); + parts.push(part( + format!("{station}:front-glass"), + "glass", + [0.0, -25.0, 80.0], + [1400.0, 12.0, 520.0], + )); + parts.push(part( + format!("{station}:status-light"), + "accent", + [-90.0, -20.0, 640.0], + [1580.0, 20.0, 24.0], + )); + parts +} + +/// The on-deck thermocycler: lid seam, vents, and a status light on the +/// body the plate seats on. +fn odtc_assembly(station: &str) -> Vec { + let mut parts = bench(station, [200.0, 320.0]); + parts.push(part( + format!("{station}:base"), + "frame", + [-20.0, -20.0, 0.0], + [240.0, 360.0, 24.0], + )); + parts.push(part( + format!("{station}:lid-seam"), + "frame", + [-4.0, -4.0, 180.0], + [208.0, 328.0, 14.0], + )); + parts.push(part( + format!("{station}:vents"), + "frame", + [-12.0, 40.0, 60.0], + [12.0, 240.0, 90.0], + )); + parts.push(part( + format!("{station}:status-light"), + "accent", + [20.0, -6.0, 220.0], + [160.0, 6.0, 12.0], + )); + parts +} + +/// The plate reader: a slim body with its tray slot and status light. +fn reader_assembly(station: &str) -> Vec { + let mut parts = bench(station, [160.0, 240.0]); + parts.push(part( + format!("{station}:tray-slot"), + "frame", + [20.0, -6.0, 30.0], + [120.0, 6.0, 20.0], + )); + parts.push(part( + format!("{station}:status-light"), + "accent", + [130.0, -6.0, 90.0], + [16.0, 6.0, 10.0], + )); + parts +} + +/// The cosmetic assembly for a station kind. Every kind at least stands +/// on a bench. +pub(crate) fn assembly(station: &str, kind: &str, footprint: [f64; 2]) -> Vec { + match kind { + "hamilton.star" => star_assembly(station), + "inheco.odtc" => odtc_assembly(station), + "byonoy.absorbance96" => reader_assembly(station), + _ => bench(station, footprint), + } +} diff --git a/crates/lab-scene/src/lib.rs b/crates/lab-scene/src/lib.rs index a1df91c..d2523c6 100644 --- a/crates/lab-scene/src/lib.rs +++ b/crates/lab-scene/src/lib.rs @@ -16,6 +16,7 @@ pub mod animate; pub mod assets; pub mod dims; pub mod gltf; +pub(crate) mod instruments; pub mod scene; pub mod star; pub mod usda; diff --git a/crates/lab-scene/src/scene.rs b/crates/lab-scene/src/scene.rs index 59e3491..c1c7243 100644 --- a/crates/lab-scene/src/scene.rs +++ b/crates/lab-scene/src/scene.rs @@ -109,6 +109,10 @@ pub enum Semantic { Labware { catalog: String }, /// One well or tip position within its labware. Well { name: String }, + /// A cosmetic piece of an instrument or bench, carrying its material + /// name: `frame`, `panel`, `glass`, or `accent`. Parts never bind to + /// trace events. + Part { material: String }, } /// Extents in millimeters. Positions in the scene are exact; extents are diff --git a/crates/lab-scene/src/usda.rs b/crates/lab-scene/src/usda.rs index c9bbe9e..fabc46c 100644 --- a/crates/lab-scene/src/usda.rs +++ b/crates/lab-scene/src/usda.rs @@ -103,11 +103,18 @@ fn material_name(semantic: &Semantic, id: &str) -> &'static str { Semantic::Well { .. } => "lab_well", Semantic::Room => "lab_room", Semantic::Station { .. } | Semantic::Site { .. } => "lab_station", + Semantic::Part { material } => match material.as_str() { + "frame" => "lab_frame", + "panel" => "lab_panel", + "glass" => "lab_glass", + "accent" => "lab_accent", + _ => "lab_station", + }, } } /// `(name, diffuse rgb, roughness, metallic, opacity)` per material. -const MATERIALS: [(&str, [f64; 3], f64, f64, f64); 7] = [ +const MATERIALS: [(&str, [f64; 3], f64, f64, f64); 11] = [ ("lab_deck", [0.55, 0.56, 0.58], 0.9, 0.4, 1.0), ("lab_carrier", [0.33, 0.34, 0.38], 0.55, 0.8, 1.0), ("lab_plate", [0.92, 0.92, 0.94], 0.5, 0.0, 1.0), @@ -115,6 +122,10 @@ const MATERIALS: [(&str, [f64; 3], f64, f64, f64); 7] = [ ("lab_well", [0.30, 0.55, 0.90], 0.3, 0.0, 0.45), ("lab_station", [0.62, 0.64, 0.68], 0.85, 0.3, 1.0), ("lab_room", [0.82, 0.82, 0.80], 0.95, 0.0, 1.0), + ("lab_frame", [0.16, 0.17, 0.20], 0.45, 0.8, 1.0), + ("lab_panel", [0.80, 0.81, 0.83], 0.55, 0.3, 1.0), + ("lab_glass", [0.65, 0.75, 0.82], 0.05, 0.0, 0.22), + ("lab_accent", [0.10, 0.55, 0.75], 0.3, 0.0, 1.0), ]; struct Emitter<'context> { diff --git a/crates/lab-scene/src/workcell.rs b/crates/lab-scene/src/workcell.rs index 606178a..3204ddb 100644 --- a/crates/lab-scene/src/workcell.rs +++ b/crates/lab-scene/src/workcell.rs @@ -125,6 +125,17 @@ pub fn workcell_scene( node.rotation_z_deg = placed .and_then(|declaration| declaration.rotation_deg) .unwrap_or(0.0); + // A real asset carries its own body detail; the procedural + // assembly dresses the box tier so a bare facility still reads + // as a lab. + if !matches!(node.geometry, Some(Geometry::Mesh { .. })) { + let extent = station_extent(&station.kind); + node.children.extend(crate::instruments::assembly( + &station.name, + &station.kind, + [extent[0], extent[1]], + )); + } if let Some(profile) = &station.star_profile { // The deck scene is in the machine's own frame, which shares // the station node's origin. diff --git a/render/lab_blender.py b/render/lab_blender.py index a58f4e2..14f7002 100644 --- a/render/lab_blender.py +++ b/render/lab_blender.py @@ -66,6 +66,10 @@ def load_documents(args): "lab.station": ((0.55, 0.57, 0.60), 0.5, 0.4, 1.0), "lab.room": ((0.75, 0.74, 0.71), 0.9, 0.0, 1.0), "lab.head": ((0.08, 0.08, 0.10), 0.35, 0.6, 1.0), + "lab.frame": ((0.06, 0.065, 0.08), 0.4, 0.85, 1.0), + "lab.panel": ((0.58, 0.59, 0.62), 0.5, 0.3, 1.0), + "lab.glass": ((0.6, 0.72, 0.78), 0.03, 0.0, 0.15), + "lab.accent": ((0.02, 0.25, 0.45), 0.3, 0.0, 1.0), } @@ -87,6 +91,11 @@ def build_materials(): def material_for(node, materials): kind = node.get("semantic", {}).get("kind", "") + if kind == "part": + name = "lab." + str(node.get("semantic", {}).get("material", "")) + if name in materials: + return materials[name] + return materials["lab.station"] catalog = str(node.get("semantic", {}).get("catalog", "")) + node.get("id", "") if kind == "room": return materials["lab.room"] @@ -219,7 +228,9 @@ def node(self, node, parent, origin): "cylinder": lambda g: (g["diameter"], g["diameter"], g["height"]), "mesh": lambda g: tuple(g["fallback"]), }[geometry["shape"]](geometry) - self.grow_bounds(here, extent) + # The room shell renders but never drives the camera framing. + if node.get("semantic", {}).get("kind") != "room": + self.grow_bounds(here, extent) if node.get("semantic", {}).get("kind") == "station": self.station_heights[node["id"]] = extent[2] self.geometry_object(node, geometry, group) @@ -250,10 +261,13 @@ def frame(self, t): return 1 + max(0.0, t - self.from_t) / self.speedup * self.fps def key_location(self, obj, t, interpolation="LINEAR"): + # Interpolation rides the insert itself: the fcurve API changed + # shape across Blender 4/5, the new-keyframe preference did not. + prefs = bpy.context.preferences.edit + previous = prefs.keyframe_new_interpolation_type + prefs.keyframe_new_interpolation_type = interpolation obj.keyframe_insert(data_path="location", frame=self.frame(t)) - action = obj.animation_data.action - for fcurve in action.fcurves: - fcurve.keyframe_points[-1].interpolation = interpolation + prefs.keyframe_new_interpolation_type = previous def head_for(self, station): if station in self.heads: @@ -353,6 +367,9 @@ def build_camera(preset, bounds_min, bounds_max, frame_end, fps): camera_data = bpy.data.cameras.new("lab-camera") camera_data.lens = 35 + # The scene is millimeters; the default clip range is meters-sized. + camera_data.clip_start = 5.0 + camera_data.clip_end = max(100000.0, size * 30.0) camera_data.dof.use_dof = True camera_data.dof.focus_object = target camera_data.dof.aperture_fstop = 2.8 @@ -375,9 +392,6 @@ def build_camera(preset, bounds_min, bounds_max, frame_end, fps): camera.keyframe_insert( data_path="location", frame=1 + step * (frame_end - 1) / steps ) - for fcurve in camera.animation_data.action.fcurves: - for point in fcurve.keyframe_points: - point.interpolation = "LINEAR" else: # dolly camera.location = ( bounds_min[0] - size * 0.15, @@ -391,9 +405,6 @@ def build_camera(preset, bounds_min, bounds_max, frame_end, fps): center[2] + size * 0.35, ) camera.keyframe_insert(data_path="location", frame=frame_end) - for fcurve in camera.animation_data.action.fcurves: - for point in fcurve.keyframe_points: - point.interpolation = "LINEAR" def configure_render(args, out_dir): @@ -408,7 +419,13 @@ def configure_render(args, out_dir): scene.cycles.samples = 512 scene.cycles.use_denoising = True else: - scene.render.engine = "BLENDER_EEVEE_NEXT" + # EEVEE's identifier moved between Blender generations. + for engine in ("BLENDER_EEVEE_NEXT", "BLENDER_EEVEE"): + try: + scene.render.engine = engine + break + except TypeError: + continue scene.eevee.taa_render_samples = 64 @@ -422,6 +439,7 @@ def main(): bpy.data.objects.remove(obj, do_unlink=True) bpy.context.scene.unit_settings.system = "METRIC" bpy.context.scene.unit_settings.scale_length = 0.001 + bpy.context.preferences.edit.keyframe_new_interpolation_type = "LINEAR" materials = build_materials() builder = Builder(materials, os.path.dirname(os.path.abspath(args.scene))) diff --git a/viewer/src/main.ts b/viewer/src/main.ts index e1d13c4..97b2e0d 100644 --- a/viewer/src/main.ts +++ b/viewer/src/main.ts @@ -69,10 +69,23 @@ const MATERIALS: Record = { roughness: 0.95, side: THREE.BackSide, }), + frame: new THREE.MeshStandardMaterial({ color: 0x2a2c33, roughness: 0.45, metalness: 0.8 }), + panel: new THREE.MeshStandardMaterial({ color: 0xcccfd4, roughness: 0.55, metalness: 0.3 }), + glass: new THREE.MeshPhysicalMaterial({ + color: 0xa6c0cc, + roughness: 0.05, + metalness: 0.0, + transparent: true, + opacity: 0.22, + }), + accent: new THREE.MeshStandardMaterial({ color: 0x1a8cbf, roughness: 0.3 }), }; function materialFor(node: SceneNode): THREE.MeshStandardMaterial { const kind = node.semantic.kind; + if (kind === "part") { + return MATERIALS[String(node.semantic.material)] ?? MATERIALS.station; + } if (kind === "room") return MATERIALS.room; if (kind === "deck") return MATERIALS.deck; if (kind === "carrier") return MATERIALS.carrier; @@ -162,7 +175,8 @@ function buildNode(node: SceneNode): THREE.Object3D { } const isWell = node.semantic.kind === "well"; const isRoom = node.semantic.kind === "room"; - mesh.castShadow = !isWell && !isRoom; + const isGlass = node.semantic.kind === "part" && node.semantic.material === "glass"; + mesh.castShadow = !isWell && !isRoom && !isGlass; mesh.receiveShadow = true; group.add(mesh); if (node.semantic.kind === "station") { From b744f65609a673d45c92cd7474664d0128103f50 Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Thu, 13 Aug 2026 17:19:40 -0600 Subject: [PATCH 10/17] Make simulate, scene, and render package-aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From a package directory, the three commands take no arguments: lab build compiles the manifest's default target into .lab/build, and lab simulate, lab scene, and lab render walk that target's waves in order. The facility resolves by convention: facility.toml at the package root, then the manifest's [build] facility pointer under facilities/, with --facility always winning. lab render is the whole flow in one command — it regenerates the simulation and the animated scene idempotently before Blender runs, so a fresh checkout needs only build and render. golden-gate now defaults to the workcell-star target and keeps its facility at the root, so its README flow is three bare commands. A named run directory keeps its exact previous behavior on every command. --- crates/lab-cli/src/flow.rs | 156 ++++++++++++++++++ crates/lab-cli/src/main.rs | 25 ++- crates/lab-cli/src/render.rs | 72 +++++--- crates/lab-cli/src/scene.rs | 105 ++++++++---- crates/lab-cli/src/simulate.rs | 60 +++++-- crates/lab-cli/tests/project_workflow.rs | 107 +++++++++++- .../language/decisions/0035-facility-files.md | 11 +- examples/golden-gate/README.md | 27 +-- .../main-bench.toml => facility.toml} | 8 +- examples/golden-gate/lab.toml | 2 +- 10 files changed, 476 insertions(+), 97 deletions(-) create mode 100644 crates/lab-cli/src/flow.rs rename examples/golden-gate/{facilities/main-bench.toml => facility.toml} (75%) diff --git a/crates/lab-cli/src/flow.rs b/crates/lab-cli/src/flow.rs new file mode 100644 index 0000000..5e31ee1 --- /dev/null +++ b/crates/lab-cli/src/flow.rs @@ -0,0 +1,156 @@ +//! Package-aware resolution for the simulation commands. +//! +//! `lab simulate`, `lab scene`, and `lab render` accept either a run +//! directory (a workcell wave or a STAR package) or a package directory, +//! defaulting to the current one. Pointed at a package, they find the +//! built output of the manifest's default target under `.lab/build/` and +//! walk its waves in order. +//! +//! The facility resolves by convention unless `--facility` names one: +//! `facility.toml` at the package root (the single-facility case), then +//! the manifest's `[build] facility` pointer under `facilities/`. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use lab_package::LabManifest; + +/// Where a command's run directories and facility ended up. +pub(crate) struct RunFlow { + /// Ordered runnable directories: the waves of a build, or the one + /// directory the caller named. + pub waves: Vec, + pub facility: Option, +} + +/// True when the directory itself holds run documents. +fn is_run_directory(path: &Path) -> bool { + path.join(lab_runfmt::WORKCELL_PLAN_FILE).is_file() + || path.join("automation_manifest.json").is_file() +} + +/// The nearest enclosing package root, for a wave directory that lives +/// under one. +fn package_root_above(path: &Path) -> Option { + let mut current = path.canonicalize().ok()?; + for _ in 0..8 { + if current.join("lab.toml").is_file() { + return Some(current); + } + current = current.parent()?.to_path_buf(); + } + None +} + +fn package_manifest(root: &Path) -> Result { + let path = root.join("lab.toml"); + let text = std::fs::read_to_string(&path) + .with_context(|| format!("failed to read {}", path.display()))?; + match LabManifest::parse(&text) + .with_context(|| format!("failed to parse {}", path.display()))? + { + LabManifest::Package(manifest) => Ok(manifest), + LabManifest::Workspace(_) => bail!( + "{} is a workspace manifest; run this command from a member package", + path.display() + ), + } +} + +/// The facility a package implies: `facility.toml` at the root wins, then +/// the manifest's `[build] facility` pointer under `facilities/`. +fn facility_for_root(root: &Path) -> Result> { + let single = root.join("facility.toml"); + if single.is_file() { + return Ok(Some(single)); + } + let manifest = package_manifest(root)?; + if let Some(name) = &manifest.build.facility { + let path = root + .join(lab_runfmt::facility::FACILITY_DIR) + .join(format!("{name}.toml")); + if !path.is_file() { + bail!( + "the manifest names facility '{name}', but there is no {}", + path.display() + ); + } + return Ok(Some(path)); + } + Ok(None) +} + +/// Resolves what to operate on. Explicit facility paths always win over +/// the package conventions. +pub(crate) fn resolve(path: &Path, explicit_facility: Option) -> Result { + if is_run_directory(path) { + let facility = match explicit_facility { + Some(explicit) => Some(explicit), + None => match package_root_above(path) { + Some(root) => facility_for_root(&root)?, + None => None, + }, + }; + return Ok(RunFlow { + waves: vec![path.to_path_buf()], + facility, + }); + } + + if !path.join("lab.toml").is_file() { + bail!( + "{} is neither a run directory nor a package: no {}, automation_manifest.json, or lab.toml", + path.display(), + lab_runfmt::WORKCELL_PLAN_FILE + ); + } + let manifest = package_manifest(path)?; + let target = manifest + .build + .target + .clone() + .context("the manifest sets no [build] target; name a run directory instead")?; + let build_dir = path.join(".lab").join("build").join(&target); + if !build_dir.is_dir() { + bail!( + "no build output at {}; run `lab build` first", + build_dir.display() + ); + } + + let mut waves: Vec = std::fs::read_dir(&build_dir) + .with_context(|| format!("failed to read {}", build_dir.display()))? + .filter_map(|entry| entry.ok().map(|entry| entry.path())) + .filter(|entry| { + entry.is_dir() + && entry + .file_name() + .is_some_and(|name| name.to_string_lossy().starts_with("wave-")) + && is_run_directory(entry) + }) + .collect(); + waves.sort(); + if waves.is_empty() { + if is_run_directory(&build_dir) { + waves.push(build_dir.clone()); + } else { + bail!( + "{} holds no run documents to simulate; target '{target}' does not emit them — build a hamilton.star or workcell target", + build_dir.display() + ); + } + } + + let facility = match explicit_facility { + Some(explicit) => Some(explicit), + None => facility_for_root(path)?, + }; + Ok(RunFlow { waves, facility }) +} + +/// A short label for one wave in multi-wave output. +pub(crate) fn wave_label(path: &Path) -> String { + path.file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()) +} diff --git a/crates/lab-cli/src/main.rs b/crates/lab-cli/src/main.rs index ac62eee..7bdbfed 100644 --- a/crates/lab-cli/src/main.rs +++ b/crates/lab-cli/src/main.rs @@ -1,4 +1,5 @@ mod commands; +mod flow; mod render; mod run; mod scene; @@ -94,8 +95,10 @@ enum Command { /// walk-away window lasts. Touches no hardware and writes no ledger; /// the full record lands in a `lab.sim-trace.v0` trace file. Simulate { - /// A run directory produced by `lab build`, workcell wave or - /// Hamilton STAR package. + /// A run directory (workcell wave or Hamilton STAR package), or a + /// package directory whose built default target is simulated wave + /// by wave. Defaults to the current directory. + #[arg(default_value = ".")] path: PathBuf, /// Where to write the trace; defaults to `sim-trace.json` beside /// the plan. @@ -109,8 +112,10 @@ enum Command { /// Render a built run package as a 3D scene: a `lab.scene.v0` /// document plus glTF and USD projections of it. Scene { - /// A run directory produced by `lab build`, workcell wave or - /// Hamilton STAR package. + /// A run directory, or a package directory whose built default + /// target is rendered wave by wave. Defaults to the current + /// directory. + #[arg(default_value = ".")] path: PathBuf, /// Where to write scene files; defaults to the run directory. #[arg(long)] @@ -127,8 +132,10 @@ enum Command { /// Render the simulated run as photographic frames (and a movie when /// ffmpeg is present) through a headless Blender. Render { - /// A run directory holding scene.json and sim-trace.json, from - /// `lab scene` and `lab simulate`. + /// A run directory or package directory. The simulation and the + /// animated scene regenerate first, so this one command is the + /// whole flow. Defaults to the current directory. + #[arg(default_value = ".")] path: PathBuf, /// Camera preset. #[arg(long, default_value = "dolly")] @@ -154,6 +161,10 @@ enum Command { /// Where to write frames; defaults to `renders/` beside the scene. #[arg(long)] out_dir: Option, + /// Facility description; defaults to the package's facility.toml + /// or its manifest's [build] facility pointer. + #[arg(long)] + facility: Option, }, /// Print resolved package metadata and source-module names. Metadata { @@ -254,6 +265,7 @@ fn run() -> Result<()> { hdri, blender, out_dir, + facility, } => render::render( path, render::RenderOptions { @@ -265,6 +277,7 @@ fn run() -> Result<()> { hdri, blender, out_dir, + facility, }, &output, ), diff --git a/crates/lab-cli/src/render.rs b/crates/lab-cli/src/render.rs index fe30a78..d6eaf24 100644 --- a/crates/lab-cli/src/render.rs +++ b/crates/lab-cli/src/render.rs @@ -26,6 +26,7 @@ pub(crate) struct RenderOptions { pub hdri: Option, pub blender: Option, pub out_dir: Option, + pub facility: Option, } /// Finds a Blender to run: the flag, the environment, the path, then the @@ -54,23 +55,17 @@ fn find_blender(flag: Option<&Path>) -> Result { ); } -pub(crate) fn render(directory: PathBuf, options: RenderOptions, output: &Output) -> Result<()> { +/// Renders one run directory whose scene and trace already exist. +/// Returns the movie path when ffmpeg assembled one. +fn render_wave( + directory: &Path, + options: &RenderOptions, + out_dir_override: Option, +) -> Result<(PathBuf, Option)> { let scene_path = directory.join("scene.json"); - if !scene_path.is_file() { - bail!( - "no scene at {}; run `lab scene` on this package first", - scene_path.display() - ); - } let trace_path = directory.join("sim-trace.json"); - if !trace_path.is_file() { - bail!( - "no trace at {}; run `lab simulate` on this package first", - trace_path.display() - ); - } let blender = find_blender(options.blender.as_deref())?; - let out_dir = options.out_dir.unwrap_or_else(|| directory.join("renders")); + let out_dir = out_dir_override.unwrap_or_else(|| directory.join("renders")); std::fs::create_dir_all(&out_dir) .with_context(|| format!("failed to create {}", out_dir.display()))?; @@ -138,19 +133,48 @@ pub(crate) fn render(directory: PathBuf, options: RenderOptions, output: &Output } } - output.success( - "render", - serde_json::json!({ - "out": out_dir.display().to_string(), - "movie": movie.as_ref().map(|path| path.display().to_string()), - }), - format!( - "rendered under {}{}", + Ok((out_dir, movie)) +} + +/// The whole cinematic flow: simulate, build the animated scene, render. +/// Every step is idempotent, so `lab render` alone is always enough. +pub(crate) fn render(directory: PathBuf, options: RenderOptions, output: &Output) -> Result<()> { + let flow = crate::flow::resolve(&directory, options.facility.clone())?; + let single = flow.waves.len() == 1; + + let mut sections = Vec::new(); + let mut reports = Vec::new(); + for wave in &flow.waves { + let label = crate::flow::wave_label(wave); + println!("== {label}: simulate =="); + crate::simulate::simulate_wave(wave, flow.facility.as_deref(), None)?; + println!("== {label}: scene =="); + crate::scene::generate_for(wave, flow.facility.as_deref(), true, None)?; + println!("== {label}: render =="); + let out_override = if single { + options.out_dir.clone() + } else { + None + }; + let (out_dir, movie) = render_wave(wave, &options, out_override)?; + sections.push(format!( + "{label}: rendered under {}{}", out_dir.display(), match &movie { Some(path) => format!("\nmovie: {}", path.display()), None => String::new(), } - ), - ) + )); + reports.push(serde_json::json!({ + "wave": label, + "out": out_dir.display().to_string(), + "movie": movie.as_ref().map(|path| path.display().to_string()), + })); + } + + if let [report] = reports.as_slice() { + let human = sections.remove(0); + return output.success("render", report, human); + } + output.success("render", reports, sections.join("\n")) } diff --git a/crates/lab-cli/src/scene.rs b/crates/lab-cli/src/scene.rs index 718223b..c02b763 100644 --- a/crates/lab-cli/src/scene.rs +++ b/crates/lab-cli/src/scene.rs @@ -137,20 +137,50 @@ fn load_trace(directory: &Path) -> Result { Ok(trace) } -pub(crate) fn scene( - directory: PathBuf, - out_dir: Option, - facility_path: Option, +/// What one scene generation produced, for reporting. +pub(crate) struct SceneOutputs { + pub name: String, + pub nodes: usize, + pub scene: PathBuf, + pub gltf: PathBuf, + pub usda: PathBuf, +} + +impl SceneOutputs { + fn report(&self) -> serde_json::Value { + serde_json::json!({ + "name": self.name, + "nodes": self.nodes, + "scene": self.scene.display().to_string(), + "gltf": self.gltf.display().to_string(), + "usda": self.usda.display().to_string(), + }) + } + + fn human(&self) -> String { + format!( + "scene '{}': {} node(s)\n {}\n {}\n {}", + self.name, + self.nodes, + self.scene.display(), + self.gltf.display(), + self.usda.display() + ) + } +} + +/// Builds and writes one run directory's scene bundle. Idempotent: the +/// same inputs always regenerate the same files in place. +pub(crate) fn generate_for( + directory: &Path, + facility_path: Option<&Path>, animated: bool, - output: &Output, -) -> Result<()> { - let context = facility_path - .as_deref() - .map(load_facility_context) - .transpose()?; - let mut scene = build_scene(&directory, context.as_ref())?; - let trace = animated.then(|| load_trace(&directory)).transpose()?; - let out_dir = out_dir.unwrap_or_else(|| directory.clone()); + out_dir: Option, +) -> Result { + let context = facility_path.map(load_facility_context).transpose()?; + let mut scene = build_scene(directory, context.as_ref())?; + let trace = animated.then(|| load_trace(directory)).transpose()?; + let out_dir = out_dir.unwrap_or_else(|| directory.to_path_buf()); lab_scene::assets::bundle_assets(&mut scene, &out_dir) .context("failed to bundle scene assets")?; std::fs::create_dir_all(&out_dir) @@ -172,21 +202,36 @@ pub(crate) fn scene( let mut nodes = 0usize; scene.root.walk(&mut |_, _| nodes += 1); - output.success( - "scene", - serde_json::json!({ - "name": scene.name, - "nodes": nodes, - "scene": scene_path.display().to_string(), - "gltf": gltf_path.display().to_string(), - "usda": usda_path.display().to_string(), - }), - format!( - "scene '{}': {nodes} node(s)\n {}\n {}\n {}", - scene.name, - scene_path.display(), - gltf_path.display(), - usda_path.display() - ), - ) + Ok(SceneOutputs { + name: scene.name, + nodes, + scene: scene_path, + gltf: gltf_path, + usda: usda_path, + }) +} + +pub(crate) fn scene( + directory: PathBuf, + out_dir: Option, + facility_path: Option, + animated: bool, + output: &Output, +) -> Result<()> { + let flow = crate::flow::resolve(&directory, facility_path)?; + + if let [wave] = flow.waves.as_slice() { + let outputs = generate_for(wave, flow.facility.as_deref(), animated, out_dir)?; + return output.success("scene", outputs.report(), outputs.human()); + } + + let mut sections = Vec::new(); + let mut reports = Vec::new(); + for wave in &flow.waves { + let label = crate::flow::wave_label(wave); + let outputs = generate_for(wave, flow.facility.as_deref(), animated, None)?; + sections.push(format!("== {label} ==\n{}", outputs.human())); + reports.push(outputs.report()); + } + output.success("scene", reports, sections.join("\n\n")) } diff --git a/crates/lab-cli/src/simulate.rs b/crates/lab-cli/src/simulate.rs index 928150a..f9be04a 100644 --- a/crates/lab-cli/src/simulate.rs +++ b/crates/lab-cli/src/simulate.rs @@ -5,7 +5,7 @@ //! when an operator must be present, and how long each walk-away window //! lasts. The full record is written as a `lab.sim-trace.v0` trace. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use lab_runtime::clock::{Clock, WallClock}; @@ -20,22 +20,23 @@ use crate::Output; /// The trace file a simulation writes beside the package it simulated. const TRACE_FILE: &str = "sim-trace.json"; -pub(crate) fn simulate( - directory: PathBuf, +/// Simulates one run directory and writes its trace beside the plan. +/// The idempotent core `lab render` reuses. +pub(crate) fn simulate_wave( + directory: &Path, + facility_path: Option<&Path>, trace_path: Option, - facility_path: Option, - output: &Output, -) -> Result<()> { +) -> Result<(SimTraceDocument, PathBuf)> { let mut durations = DurationModel::default(); let facility = facility_path - .map(|path| lab_runtime::facility::load_facility(&path)) + .map(lab_runtime::facility::load_facility) .transpose()?; if let Some(facility) = &facility { // The facility's transport time is the whole handoff: seal, carry, // seat, confirm. durations.handoff_seconds = facility.transport.walk_seconds; - if is_workcell_directory(&directory) { - let plan = lab_runfmt::load_workcell_plan(&directory)?; + if is_workcell_directory(directory) { + let plan = lab_runfmt::load_workcell_plan(directory)?; facility.check_stations(&plan.stations)?; } } @@ -43,20 +44,51 @@ pub(crate) fn simulate( origin_unix: WallClock.now_unix(), durations, }; - let trace = if is_workcell_directory(&directory) { - let loaded = load_workcell_directory(&directory)?; + let trace = if is_workcell_directory(directory) { + let loaded = load_workcell_directory(directory)?; simulate_workcell(&loaded, config)? } else { - simulate_star_package(&directory, config)? + simulate_star_package(directory, config)? }; let trace_path = trace_path.unwrap_or_else(|| directory.join(TRACE_FILE)); let text = serde_json::to_string_pretty(&trace)?; std::fs::write(&trace_path, text) .with_context(|| format!("failed to write {}", trace_path.display()))?; + Ok((trace, trace_path)) +} + +pub(crate) fn simulate( + directory: PathBuf, + trace_path: Option, + facility_path: Option, + output: &Output, +) -> Result<()> { + let flow = crate::flow::resolve(&directory, facility_path)?; + + // One named run directory keeps its exact single-wave contract. + if let [wave] = flow.waves.as_slice() { + let (trace, written) = simulate_wave(wave, flow.facility.as_deref(), trace_path)?; + let human = render_timeline(&trace, &written); + return output.success("simulate", &trace, human); + } - let human = render_timeline(&trace, &trace_path); - output.success("simulate", &trace, human) + let mut sections = Vec::new(); + let mut reports = Vec::new(); + for wave in &flow.waves { + let label = crate::flow::wave_label(wave); + let (trace, written) = simulate_wave(wave, flow.facility.as_deref(), None)?; + sections.push(format!( + "== {label} ==\n{}", + render_timeline(&trace, &written) + )); + reports.push(serde_json::json!({ + "wave": label, + "trace": written.display().to_string(), + "summary": trace.summary, + })); + } + output.success("simulate", reports, sections.join("\n\n")) } fn hms(seconds: f64) -> String { diff --git a/crates/lab-cli/tests/project_workflow.rs b/crates/lab-cli/tests/project_workflow.rs index 760ad69..e4fc570 100644 --- a/crates/lab-cli/tests/project_workflow.rs +++ b/crates/lab-cli/tests/project_workflow.rs @@ -209,10 +209,10 @@ fn the_manifest_target_builds_robot_protocols_without_naming_one() { String::from_utf8_lossy(&default_target.stderr) ); let result: Value = serde_json::from_slice(&default_target.stdout).unwrap(); - assert_eq!(result["result"]["target"], "opentrons-ot2"); + assert_eq!(result["result"]["target"], "workcell-star"); assert!( out_dir - .join("opentrons-ot2/wave-001/assembly_protocol.py") + .join("workcell-star/wave-001/plan.workcell.json") .is_file() ); @@ -237,7 +237,7 @@ fn the_manifest_target_builds_robot_protocols_without_naming_one() { let result: Value = serde_json::from_slice(&ir_only.stdout).unwrap(); assert_eq!(result["result"]["target"], Value::Null); assert!(result["result"]["protocols"].as_array().unwrap().is_empty()); - assert!(!out_dir.join("opentrons-ot2").exists()); + assert!(!out_dir.join("workcell-star").exists()); assert!(out_dir.join("package.json").is_file()); std::fs::remove_dir_all(out_dir).unwrap(); @@ -783,7 +783,7 @@ fn a_facility_lays_out_the_scene_with_room_and_assets() { b"glTF-stub".as_slice(), ) .unwrap(); - let source = std::fs::read_to_string(example.join("facilities/main-bench.toml")).unwrap(); + let source = std::fs::read_to_string(example.join("facility.toml")).unwrap(); std::fs::write(facility_dir.join("main-bench.toml"), source).unwrap(); let rendered = Command::new(env!("CARGO_BIN_EXE_lab")) @@ -998,3 +998,102 @@ fn a_wave_renders_one_photographic_frame_through_blender() { std::fs::remove_dir_all(&out_dir).unwrap(); } + +#[test] +fn the_zero_argument_flow_simulates_a_package_from_its_root() { + // Copy the example so the flow's .lab/build output stays out of the + // repository. + let example = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../examples/golden-gate") + .canonicalize() + .unwrap(); + let package = std::env::temp_dir().join(format!( + "lab-golden-gate-flow-{}-{}", + std::process::id(), + line!() + )); + if package.exists() { + std::fs::remove_dir_all(&package).unwrap(); + } + let copy = Command::new("cp") + .args(["-R", example.to_str().unwrap(), package.to_str().unwrap()]) + .output() + .unwrap(); + assert!(copy.status.success()); + std::fs::remove_dir_all(package.join(".lab")).ok(); + + // `lab build` with no arguments, from the package directory: the + // manifest's workcell target lands under .lab/build/. + let build = Command::new(env!("CARGO_BIN_EXE_lab")) + .current_dir(&package) + .arg("build") + .output() + .unwrap(); + assert!( + build.status.success(), + "package build failed: {}", + String::from_utf8_lossy(&build.stderr) + ); + assert!( + package + .join(".lab/build/workcell-star/wave-001/plan.workcell.json") + .is_file() + ); + + // `lab simulate` with no arguments: every wave, facility by + // convention from facility.toml at the root. + let simulate = Command::new(env!("CARGO_BIN_EXE_lab")) + .current_dir(&package) + .args(["simulate", "--json"]) + .output() + .unwrap(); + assert!( + simulate.status.success(), + "package simulate failed: {}", + String::from_utf8_lossy(&simulate.stderr) + ); + let report: Value = serde_json::from_slice(&simulate.stdout).unwrap(); + let waves = report["result"].as_array().unwrap(); + assert_eq!(waves.len(), 2, "both waves simulate: {report}"); + assert_eq!(waves[0]["wave"], "wave-001"); + for wave in ["wave-001", "wave-002"] { + assert!( + package + .join(format!(".lab/build/workcell-star/{wave}/sim-trace.json")) + .is_file() + ); + } + // The facility's 45 s walk shortened handoffs: the summary's attended + // time proves facility.toml was picked up without a flag (default + // handoffs would cost 90 s each). + let attended = waves[0]["summary"]["attended_seconds"].as_f64().unwrap(); + assert!( + (attended - 90.0).abs() < 1.0, + "two 45 s facility handoffs, not two 90 s defaults: {attended}" + ); + + // `lab scene` with no arguments covers every wave too. + let scene = Command::new(env!("CARGO_BIN_EXE_lab")) + .current_dir(&package) + .args(["scene", "--animated", "--json"]) + .output() + .unwrap(); + assert!( + scene.status.success(), + "package scene failed: {}", + String::from_utf8_lossy(&scene.stderr) + ); + for wave in ["wave-001", "wave-002"] { + let usda = std::fs::read_to_string( + package.join(format!(".lab/build/workcell-star/{wave}/scene.usda")), + ) + .unwrap(); + assert!(usda.contains("timeSamples"), "{wave} is animated"); + assert!( + usda.contains("room_floor") || usda.contains("room:floor"), + "{wave} renders the facility room" + ); + } + + std::fs::remove_dir_all(&package).unwrap(); +} diff --git a/docs/language/decisions/0035-facility-files.md b/docs/language/decisions/0035-facility-files.md index bbf2075..09d8564 100644 --- a/docs/language/decisions/0035-facility-files.md +++ b/docs/language/decisions/0035-facility-files.md @@ -29,10 +29,13 @@ takes in this facility. `lab simulate --facility` checks that every station a plan needs exists there by name and kind, and drives handoff durations from the facility's transport time. -The manifest carries at most a pointer: `[build] facility = "main-bench"`, -a bare name held to the same no-path-escape rule as `[build] target`. The -description never lives in `lab.toml`, and station addresses stay runtime -input either way. +A single-facility package keeps the description at its root as +`facility.toml`, where the simulation commands find it by convention. +Packages comparing several candidate facilities keep them under +`facilities/`, selected by `--facility` or by the manifest's pointer: +`[build] facility = "main-bench"`, a bare name held to the same +no-path-escape rule as `[build] target`. The description never lives in +`lab.toml` itself, and station addresses stay runtime input either way. Stock is inventory state, not a declaration (0026's companion rule): storage units name materials and artifacts by their declared identities. diff --git a/examples/golden-gate/README.md b/examples/golden-gate/README.md index 6d2ca7b..ffbf1eb 100644 --- a/examples/golden-gate/README.md +++ b/examples/golden-gate/README.md @@ -34,20 +34,25 @@ From the `examples/golden-gate` directory, run: lab build ``` -The manifest declares `[build] target = "opentrons-ot2"`, so a plain `lab build` -compiles for that bench; `lab build --target ` compiles for another one, -and `lab build --no-target` stops at portable module IR. +The manifest declares `[build] target = "workcell-star"`, so a plain +`lab build` compiles for the simulatable workcell; `lab build --target +` compiles for another bench, and `lab build --no-target` stops at +portable module IR. -The build prints the path of every runnable protocol it emitted: +The build writes run documents under `.lab/build/workcell-star/`, one +directory per planning wave, and from here the whole simulation flow is +zero-argument: -```text -Robot protocols: - .../.lab/build/opentrons-ot2/wave-001/assembly_protocol.py - .../.lab/build/opentrons-ot2/wave-002/plating_protocol.py - .../.lab/build/opentrons-ot2/wave-002/transformation_protocol.py +```bash +lab simulate # timeline, attended vs walk-away, sim-trace.json per wave +lab render # simulate + animated scene + Blender frames, per wave ``` -It writes those under `.lab/build/opentrons-ot2/`, one directory per planning wave: +`facility.toml` at the package root describes the room the simulation runs +in: station positions, storage, and transport times. `lab simulate` and +`lab render` pick it up by convention; `--facility` names another one. + +The build output holds, per target directory: | Path | Contents | | --- | --- | @@ -123,6 +128,8 @@ lab run examples/golden-gate/.lab/build/workcell-star/wave-001 --dry-run ## See the deck +`targets/opentrons-ot2.toml` describes an OT-2 (`lab build --target +opentrons-ot2` emits `*_protocol.py` under `.lab/build/opentrons-ot2/`). Open the Opentrons app, go to **Protocols**, and either drag one of those protocol files onto the window or use **Import a Protocol → Choose file** and paste the path the build printed. The app analyzes it and draws the deck. diff --git a/examples/golden-gate/facilities/main-bench.toml b/examples/golden-gate/facility.toml similarity index 75% rename from examples/golden-gate/facilities/main-bench.toml rename to examples/golden-gate/facility.toml index 502e18c..d6c2d60 100644 --- a/examples/golden-gate/facilities/main-bench.toml +++ b/examples/golden-gate/facility.toml @@ -1,8 +1,8 @@ # The lab this package usually runs in: the workcell-star bench plus the -# cold storage its stock lives in. One facility serves every package that -# runs here; a package's manifest carries at most a pointer to it, and -# `lab simulate --facility` compares candidate facilities by simulating the -# same build against each. +# cold storage its stock lives in. A single-facility package keeps the +# description here at the root, where `lab simulate` and `lab render` find +# it by convention; packages with several candidate facilities keep them +# under `facilities/` and compare them with `--facility`. [facility] name = "main-bench" diff --git a/examples/golden-gate/lab.toml b/examples/golden-gate/lab.toml index 14d0026..55c0059 100644 --- a/examples/golden-gate/lab.toml +++ b/examples/golden-gate/lab.toml @@ -5,7 +5,7 @@ edition = "2026" [build] entry = "src/programs/reporter_panel.lab" -target = "opentrons-ot2" +target = "workcell-star" # What this laboratory has on hand. A target build resolves every artifact # dependency against these names: a material listed here is available to a From fe91ba34e0305cdb24948ea3df35479302faa773 Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Thu, 13 Aug 2026 19:04:16 -0600 Subject: [PATCH 11/17] Parallelize lab render and quiet Blender deprecations A preview render is dominated by per-frame overhead, so --jobs splits the frame range across several Blender processes writing distinct frames into the shared directory; the timeline is identical in every chunk, so frames line up exactly. Previews default to half the cores (capped at four) and path-traced finals to one process, which already saturates the GPU. The player only touches use_nodes on datablocks that still lack a node tree, so current Blenders stop warning about its removal. --- crates/lab-cli/src/main.rs | 7 ++ crates/lab-cli/src/render.rs | 163 +++++++++++++++++++++++++++-------- render/lab_blender.py | 21 ++++- 3 files changed, 152 insertions(+), 39 deletions(-) diff --git a/crates/lab-cli/src/main.rs b/crates/lab-cli/src/main.rs index 7bdbfed..f929f61 100644 --- a/crates/lab-cli/src/main.rs +++ b/crates/lab-cli/src/main.rs @@ -165,6 +165,11 @@ enum Command { /// or its manifest's [build] facility pointer. #[arg(long)] facility: Option, + /// Blender processes per wave, each rendering a slice of the frame + /// range. Defaults to half the cores (max 4) for previews and one + /// for path-traced finals, which already saturate the GPU. + #[arg(long)] + jobs: Option, }, /// Print resolved package metadata and source-module names. Metadata { @@ -266,6 +271,7 @@ fn run() -> Result<()> { blender, out_dir, facility, + jobs, } => render::render( path, render::RenderOptions { @@ -278,6 +284,7 @@ fn run() -> Result<()> { blender, out_dir, facility, + jobs, }, &output, ), diff --git a/crates/lab-cli/src/render.rs b/crates/lab-cli/src/render.rs index d6eaf24..582bdfd 100644 --- a/crates/lab-cli/src/render.rs +++ b/crates/lab-cli/src/render.rs @@ -27,6 +27,54 @@ pub(crate) struct RenderOptions { pub blender: Option, pub out_dir: Option, pub facility: Option, + pub jobs: Option, +} + +/// How many Blender processes render one wave. Previews are dominated by +/// per-frame overhead, so several processes scale nearly linearly; a +/// path-traced final already saturates the GPU, so it defaults to one. +fn effective_jobs(options: &RenderOptions) -> usize { + if let Some(jobs) = options.jobs { + return jobs.max(1); + } + if options.quality == "final" { + return 1; + } + let cores = std::thread::available_parallelism() + .map(|cores| cores.get()) + .unwrap_or(4); + (cores / 2).clamp(1, 4) +} + +/// Splits `1..=frame_end` into up to `jobs` contiguous slices. +fn frame_chunks(frame_end: u32, jobs: usize) -> Vec<(u32, u32)> { + let jobs = (jobs as u32).clamp(1, frame_end); + let base = frame_end / jobs; + let remainder = frame_end % jobs; + let mut chunks = Vec::new(); + let mut start = 1u32; + for index in 0..jobs { + let size = base + u32::from(index < remainder); + if size == 0 { + continue; + } + chunks.push((start, start + size - 1)); + start += size; + } + chunks +} + +/// The footage frame count the player computes for this trace, mirrored +/// here so chunks can be assigned before Blender starts. +fn footage_frames(trace_path: &Path, options: &RenderOptions) -> Result { + let text = std::fs::read_to_string(trace_path) + .with_context(|| format!("failed to read {}", trace_path.display()))?; + let trace: serde_json::Value = serde_json::from_str(&text) + .with_context(|| format!("failed to parse {}", trace_path.display()))?; + let total = trace["summary"]["total_seconds"].as_f64().unwrap_or(0.0); + Ok((total / options.speedup * f64::from(options.fps)) + .ceil() + .max(2.0) as u32) } /// Finds a Blender to run: the flag, the environment, the path, then the @@ -73,42 +121,87 @@ fn render_wave( std::fs::write(&script_path, PLAYER) .with_context(|| format!("failed to write {}", script_path.display()))?; - let mut command = Command::new(&blender); - command - .arg("--background") - .arg("--factory-startup") - .arg("--python-exit-code") - .arg("1") - .arg("--python") - .arg(&script_path) - .arg("--") - .arg("--scene") - .arg(&scene_path) - .arg("--trace") - .arg(&trace_path) - .arg("--out") - .arg(&out_dir) - .arg("--camera") - .arg(&options.camera) - .arg("--speedup") - .arg(options.speedup.to_string()) - .arg("--fps") - .arg(options.fps.to_string()) - .arg("--quality") - .arg(&options.quality); - if let Some(still) = options.still { - command.arg("--still").arg(still.to_string()); - } - if let Some(hdri) = &options.hdri { - command.arg("--hdri").arg(hdri); - } + let build_command = |frames: Option<(u32, u32)>| { + let mut command = Command::new(&blender); + command + .arg("--background") + .arg("--factory-startup") + .arg("--python-exit-code") + .arg("1") + .arg("--python") + .arg(&script_path) + .arg("--") + .arg("--scene") + .arg(&scene_path) + .arg("--trace") + .arg(&trace_path) + .arg("--out") + .arg(&out_dir) + .arg("--camera") + .arg(&options.camera) + .arg("--speedup") + .arg(options.speedup.to_string()) + .arg("--fps") + .arg(options.fps.to_string()) + .arg("--quality") + .arg(&options.quality); + if let Some(still) = options.still { + command.arg("--still").arg(still.to_string()); + } + if let Some(hdri) = &options.hdri { + command.arg("--hdri").arg(hdri); + } + if let Some((start, stop)) = frames { + command + .arg("--frame-start") + .arg(start.to_string()) + .arg("--frame-end") + .arg(stop.to_string()); + } + command + }; - println!("rendering with {}", blender.display()); - let status = command - .status() - .with_context(|| format!("failed to run {}", blender.display()))?; - if !status.success() { - bail!("Blender exited with {status}"); + let jobs = if options.still.is_some() { + 1 + } else { + effective_jobs(options) + }; + if jobs == 1 { + println!("rendering with {}", blender.display()); + let status = build_command(None) + .status() + .with_context(|| format!("failed to run {}", blender.display()))?; + if !status.success() { + bail!("Blender exited with {status}"); + } + } else { + // Every process builds the identical timeline and renders its own + // slice of the frame range into the shared frames directory. + let chunks = frame_chunks(footage_frames(&trace_path, options)?, jobs); + println!( + "rendering with {} across {} process(es)", + blender.display(), + chunks.len() + ); + let mut children = Vec::new(); + for chunk in &chunks { + let child = build_command(Some(*chunk)) + .spawn() + .with_context(|| format!("failed to run {}", blender.display()))?; + children.push((*chunk, child)); + } + let mut failed = Vec::new(); + for (chunk, mut child) in children { + let status = child + .wait() + .with_context(|| format!("failed to wait for frames {}..={}", chunk.0, chunk.1))?; + if !status.success() { + failed.push(format!("frames {}..={}: {status}", chunk.0, chunk.1)); + } + } + if !failed.is_empty() { + bail!("Blender chunk(s) failed: {}", failed.join("; ")); + } } // Assemble a movie when ffmpeg is around; the frames stay either way. diff --git a/render/lab_blender.py b/render/lab_blender.py index 14f7002..97a6351 100644 --- a/render/lab_blender.py +++ b/render/lab_blender.py @@ -37,6 +37,10 @@ def parse_args(): parser.add_argument("--to-t", dest="to_t", type=float, default=None) parser.add_argument("--still", type=float, default=None, help="render one frame at this simulated second") + parser.add_argument("--frame-start", dest="frame_start", type=int, default=None, + help="first footage frame this process renders") + parser.add_argument("--frame-end", dest="frame_end", type=int, default=None, + help="last footage frame this process renders") parser.add_argument("--hdri", default=None, help="environment .hdr/.exr; the built-in sky otherwise") return parser.parse_args(argv) @@ -73,11 +77,18 @@ def load_documents(args): } +def ensure_node_tree(datablock): + """Newer Blenders build datablocks node-based from the start; only the + older ones still need (and still have) use_nodes.""" + if getattr(datablock, "node_tree", None) is None and hasattr(datablock, "use_nodes"): + datablock.use_nodes = True + + def build_materials(): built = {} for name, (rgb, roughness, metallic, alpha) in MATERIALS.items(): material = bpy.data.materials.new(name) - material.use_nodes = True + ensure_node_tree(material) bsdf = material.node_tree.nodes["Principled BSDF"] bsdf.inputs["Base Color"].default_value = (*rgb, 1.0) bsdf.inputs["Roughness"].default_value = roughness @@ -340,7 +351,7 @@ def play(self, trace): def build_world(hdri): world = bpy.data.worlds.new("lab-world") - world.use_nodes = True + ensure_node_tree(world) nodes = world.node_tree.nodes background = nodes["Background"] if hdri and os.path.isfile(hdri): @@ -452,8 +463,10 @@ def main(): animator.play(trace) frame_end = max(2, math.ceil((to_t - args.from_t) / args.speedup * args.fps)) - bpy.context.scene.frame_start = 1 - bpy.context.scene.frame_end = frame_end + # A chunked render owns a slice of the full range; the timeline and + # keyframes are identical in every chunk, so frames line up exactly. + bpy.context.scene.frame_start = max(1, args.frame_start or 1) + bpy.context.scene.frame_end = min(frame_end, args.frame_end or frame_end) build_world(args.hdri) build_camera(args.camera, builder.bounds_min, builder.bounds_max, frame_end, args.fps) From 592986e98825a9d814e81533bf56910ddea57328 Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Thu, 13 Aug 2026 19:15:06 -0600 Subject: [PATCH 12/17] Parallelize lab render and quiet Blender deprecations A preview render is dominated by per-frame overhead, so --jobs splits the frame range across several Blender processes writing distinct frames into the shared directory; the timeline is identical in every chunk, so frames line up exactly. Previews default to half the cores (capped at four) and path-traced finals to one process, which already saturates the GPU. The player only touches use_nodes on datablocks that still lack a node tree, so current Blenders stop warning about its removal. --- crates/lab-cli/src/render.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crates/lab-cli/src/render.rs b/crates/lab-cli/src/render.rs index 582bdfd..4edfe0a 100644 --- a/crates/lab-cli/src/render.rs +++ b/crates/lab-cli/src/render.rs @@ -271,3 +271,21 @@ pub(crate) fn render(directory: PathBuf, options: RenderOptions, output: &Output } output.success("render", reports, sections.join("\n")) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn frame_chunks_cover_the_range_exactly_once() { + assert_eq!(frame_chunks(10, 4), [(1, 3), (4, 6), (7, 8), (9, 10)]); + assert_eq!(frame_chunks(2, 8), [(1, 1), (2, 2)], "jobs cap at frames"); + assert_eq!(frame_chunks(315, 1), [(1, 315)]); + let chunks = frame_chunks(1351, 4); + assert_eq!(chunks.first().unwrap().0, 1); + assert_eq!(chunks.last().unwrap().1, 1351); + for pair in chunks.windows(2) { + assert_eq!(pair[1].0, pair[0].1 + 1, "no gap, no overlap"); + } + } +} From 3af211a4adbcfe96fc7ef9cbeb9337fe20519366 Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Thu, 13 Aug 2026 20:31:26 -0600 Subject: [PATCH 13/17] Skip unchanged work when rerunning the simulation flow Each step fingerprints its inputs (file identity, size, mtime) and the settings that shape its output, and stamps the fingerprint beside the result: simulate over the run documents and facility, scene over those plus the trace and assets, render over the scene, trace, and camera settings. Rerunning with nothing changed reports up to date and does no work; touching any input regenerates exactly the stale steps. Preview renders now default to every core instead of four. --- crates/lab-cli/src/main.rs | 5 +- crates/lab-cli/src/render.rs | 55 +++++++++++----- crates/lab-cli/src/scene.rs | 75 ++++++++++++++++++---- crates/lab-cli/src/simulate.rs | 47 ++++++++++++-- crates/lab-cli/src/stamp.rs | 112 +++++++++++++++++++++++++++++++++ 5 files changed, 260 insertions(+), 34 deletions(-) create mode 100644 crates/lab-cli/src/stamp.rs diff --git a/crates/lab-cli/src/main.rs b/crates/lab-cli/src/main.rs index f929f61..4ab8be7 100644 --- a/crates/lab-cli/src/main.rs +++ b/crates/lab-cli/src/main.rs @@ -4,6 +4,7 @@ mod render; mod run; mod scene; mod simulate; +mod stamp; mod typeset; mod update; mod workcell_run; @@ -166,8 +167,8 @@ enum Command { #[arg(long)] facility: Option, /// Blender processes per wave, each rendering a slice of the frame - /// range. Defaults to half the cores (max 4) for previews and one - /// for path-traced finals, which already saturate the GPU. + /// range. Previews default to every core; path-traced finals to + /// one process, which already saturates the GPU. #[arg(long)] jobs: Option, }, diff --git a/crates/lab-cli/src/render.rs b/crates/lab-cli/src/render.rs index 4edfe0a..dfe9172 100644 --- a/crates/lab-cli/src/render.rs +++ b/crates/lab-cli/src/render.rs @@ -31,8 +31,8 @@ pub(crate) struct RenderOptions { } /// How many Blender processes render one wave. Previews are dominated by -/// per-frame overhead, so several processes scale nearly linearly; a -/// path-traced final already saturates the GPU, so it defaults to one. +/// per-frame overhead, so they use every core; a path-traced final +/// already saturates the GPU, so it defaults to one. fn effective_jobs(options: &RenderOptions) -> usize { if let Some(jobs) = options.jobs { return jobs.max(1); @@ -40,10 +40,9 @@ fn effective_jobs(options: &RenderOptions) -> usize { if options.quality == "final" { return 1; } - let cores = std::thread::available_parallelism() + std::thread::available_parallelism() .map(|cores| cores.get()) - .unwrap_or(4); - (cores / 2).clamp(1, 4) + .unwrap_or(1) } /// Splits `1..=frame_end` into up to `jobs` contiguous slices. @@ -109,14 +108,31 @@ fn render_wave( directory: &Path, options: &RenderOptions, out_dir_override: Option, -) -> Result<(PathBuf, Option)> { +) -> Result<(PathBuf, Option, bool)> { let scene_path = directory.join("scene.json"); let trace_path = directory.join("sim-trace.json"); - let blender = find_blender(options.blender.as_deref())?; let out_dir = out_dir_override.unwrap_or_else(|| directory.join("renders")); std::fs::create_dir_all(&out_dir) .with_context(|| format!("failed to create {}", out_dir.display()))?; + // Skip Blender entirely when the documents and the settings that + // shape the footage both match the last run. + let settings = format!( + "camera={};speedup={};fps={};quality={};still={:?};hdri={:?}", + options.camera, options.speedup, options.fps, options.quality, options.still, options.hdri + ); + let print = crate::stamp::fingerprint(&[scene_path.clone(), trace_path.clone()], &settings); + let stamp_path = out_dir.join(".render.stamp"); + let outputs_exist = match options.still { + Some(_) => out_dir.join("frames/still.png").is_file(), + None => out_dir.join("run.mp4").is_file() || out_dir.join("frames/0001.png").is_file(), + }; + if outputs_exist && crate::stamp::is_fresh(&stamp_path, &print) { + let movie = out_dir.join("run.mp4"); + return Ok((out_dir.clone(), movie.is_file().then_some(movie), true)); + } + let blender = find_blender(options.blender.as_deref())?; + let script_path = out_dir.join("lab_blender.py"); std::fs::write(&script_path, PLAYER) .with_context(|| format!("failed to write {}", script_path.display()))?; @@ -203,6 +219,7 @@ fn render_wave( bail!("Blender chunk(s) failed: {}", failed.join("; ")); } } + crate::stamp::write(&stamp_path, &print); // Assemble a movie when ffmpeg is around; the frames stay either way. let mut movie = None; @@ -226,7 +243,7 @@ fn render_wave( } } - Ok((out_dir, movie)) + Ok((out_dir, movie, false)) } /// The whole cinematic flow: simulate, build the animated scene, render. @@ -239,17 +256,27 @@ pub(crate) fn render(directory: PathBuf, options: RenderOptions, output: &Output let mut reports = Vec::new(); for wave in &flow.waves { let label = crate::flow::wave_label(wave); - println!("== {label}: simulate =="); - crate::simulate::simulate_wave(wave, flow.facility.as_deref(), None)?; - println!("== {label}: scene =="); - crate::scene::generate_for(wave, flow.facility.as_deref(), true, None)?; - println!("== {label}: render =="); + let (_, _, sim_fresh) = + crate::simulate::simulate_wave(wave, flow.facility.as_deref(), None)?; + println!( + "== {label}: simulate{} ==", + if sim_fresh { " (up to date)" } else { "" } + ); + let (_, scene_fresh) = + crate::scene::generate_for(wave, flow.facility.as_deref(), true, None)?; + println!( + "== {label}: scene{} ==", + if scene_fresh { " (up to date)" } else { "" } + ); let out_override = if single { options.out_dir.clone() } else { None }; - let (out_dir, movie) = render_wave(wave, &options, out_override)?; + let (out_dir, movie, render_fresh) = render_wave(wave, &options, out_override)?; + if render_fresh { + println!("== {label}: render (up to date) =="); + } sections.push(format!( "{label}: rendered under {}{}", out_dir.display(), diff --git a/crates/lab-cli/src/scene.rs b/crates/lab-cli/src/scene.rs index c02b763..28e2960 100644 --- a/crates/lab-cli/src/scene.rs +++ b/crates/lab-cli/src/scene.rs @@ -176,7 +176,46 @@ pub(crate) fn generate_for( facility_path: Option<&Path>, animated: bool, out_dir: Option, -) -> Result { +) -> Result<(SceneOutputs, bool)> { + // The scene derives from the run documents, the trace (when + // animated), the facility, and its assets; skip when none changed. + let mut inputs = crate::stamp::run_document_inputs(directory); + if animated { + inputs.push(directory.join("sim-trace.json")); + } + if let Some(facility) = facility_path { + inputs.push(facility.to_path_buf()); + let assets = facility.parent().unwrap_or(Path::new(".")).join("assets"); + if let Ok(entries) = std::fs::read_dir(&assets) { + inputs.extend(entries.flatten().map(|entry| entry.path())); + } + } + let settings = format!("animated={animated};facility={facility_path:?}"); + let print = crate::stamp::fingerprint(&inputs, &settings); + let target_dir = out_dir.clone().unwrap_or_else(|| directory.to_path_buf()); + let stamp_path = target_dir.join(".scene.stamp"); + let existing = ["scene.json", "scene.gltf", "scene.usda"] + .iter() + .all(|name| target_dir.join(name).is_file()); + if existing + && crate::stamp::is_fresh(&stamp_path, &print) + && let Ok(text) = std::fs::read_to_string(target_dir.join("scene.json")) + && let Ok(scene) = serde_json::from_str::(&text) + { + let mut nodes = 0usize; + scene.root.walk(&mut |_, _| nodes += 1); + return Ok(( + SceneOutputs { + name: scene.name, + nodes, + scene: target_dir.join("scene.json"), + gltf: target_dir.join("scene.gltf"), + usda: target_dir.join("scene.usda"), + }, + true, + )); + } + let context = facility_path.map(load_facility_context).transpose()?; let mut scene = build_scene(directory, context.as_ref())?; let trace = animated.then(|| load_trace(directory)).transpose()?; @@ -200,15 +239,19 @@ pub(crate) fn generate_for( std::fs::write(&usda_path, usda) .with_context(|| format!("failed to write {}", usda_path.display()))?; + crate::stamp::write(&stamp_path, &print); let mut nodes = 0usize; scene.root.walk(&mut |_, _| nodes += 1); - Ok(SceneOutputs { - name: scene.name, - nodes, - scene: scene_path, - gltf: gltf_path, - usda: usda_path, - }) + Ok(( + SceneOutputs { + name: scene.name, + nodes, + scene: scene_path, + gltf: gltf_path, + usda: usda_path, + }, + false, + )) } pub(crate) fn scene( @@ -221,16 +264,24 @@ pub(crate) fn scene( let flow = crate::flow::resolve(&directory, facility_path)?; if let [wave] = flow.waves.as_slice() { - let outputs = generate_for(wave, flow.facility.as_deref(), animated, out_dir)?; - return output.success("scene", outputs.report(), outputs.human()); + let (outputs, fresh) = generate_for(wave, flow.facility.as_deref(), animated, out_dir)?; + let mut human = outputs.human(); + if fresh { + human.push_str("\n(up to date; nothing regenerated)"); + } + return output.success("scene", outputs.report(), human); } let mut sections = Vec::new(); let mut reports = Vec::new(); for wave in &flow.waves { let label = crate::flow::wave_label(wave); - let outputs = generate_for(wave, flow.facility.as_deref(), animated, None)?; - sections.push(format!("== {label} ==\n{}", outputs.human())); + let (outputs, fresh) = generate_for(wave, flow.facility.as_deref(), animated, None)?; + sections.push(format!( + "== {label}{} ==\n{}", + if fresh { " (up to date)" } else { "" }, + outputs.human() + )); reports.push(outputs.report()); } output.success("scene", reports, sections.join("\n\n")) diff --git a/crates/lab-cli/src/simulate.rs b/crates/lab-cli/src/simulate.rs index f9be04a..19cfac0 100644 --- a/crates/lab-cli/src/simulate.rs +++ b/crates/lab-cli/src/simulate.rs @@ -26,7 +26,31 @@ pub(crate) fn simulate_wave( directory: &Path, facility_path: Option<&Path>, trace_path: Option, -) -> Result<(SimTraceDocument, PathBuf)> { +) -> Result<(SimTraceDocument, PathBuf, bool)> { + // Nothing changed since the last run: reuse the trace on disk. An + // explicit --trace destination always regenerates. + let mut inputs = crate::stamp::run_document_inputs(directory); + if let Some(facility) = facility_path { + inputs.push(facility.to_path_buf()); + } + let settings = format!( + "durations={};facility={:?}", + DurationModel::default().name, + facility_path + ); + let print = crate::stamp::fingerprint(&inputs, &settings); + let stamp_path = directory.join(".sim-trace.stamp"); + let default_trace = directory.join(TRACE_FILE); + if trace_path.is_none() + && default_trace.is_file() + && crate::stamp::is_fresh(&stamp_path, &print) + && let Ok(text) = std::fs::read_to_string(&default_trace) + && let Ok(trace) = serde_json::from_str::(&text) + && trace.format == lab_runfmt::SIM_TRACE_FORMAT + { + return Ok((trace, default_trace, true)); + } + let mut durations = DurationModel::default(); let facility = facility_path .map(lab_runtime::facility::load_facility) @@ -51,11 +75,15 @@ pub(crate) fn simulate_wave( simulate_star_package(directory, config)? }; + let custom_destination = trace_path.is_some(); let trace_path = trace_path.unwrap_or_else(|| directory.join(TRACE_FILE)); let text = serde_json::to_string_pretty(&trace)?; std::fs::write(&trace_path, text) .with_context(|| format!("failed to write {}", trace_path.display()))?; - Ok((trace, trace_path)) + if !custom_destination { + crate::stamp::write(&stamp_path, &print); + } + Ok((trace, trace_path, false)) } pub(crate) fn simulate( @@ -68,8 +96,14 @@ pub(crate) fn simulate( // One named run directory keeps its exact single-wave contract. if let [wave] = flow.waves.as_slice() { - let (trace, written) = simulate_wave(wave, flow.facility.as_deref(), trace_path)?; - let human = render_timeline(&trace, &written); + let (trace, written, fresh) = simulate_wave(wave, flow.facility.as_deref(), trace_path)?; + let mut human = render_timeline(&trace, &written); + if fresh { + human.push_str( + " +(up to date; nothing re-simulated)", + ); + } return output.success("simulate", &trace, human); } @@ -77,9 +111,10 @@ pub(crate) fn simulate( let mut reports = Vec::new(); for wave in &flow.waves { let label = crate::flow::wave_label(wave); - let (trace, written) = simulate_wave(wave, flow.facility.as_deref(), None)?; + let (trace, written, fresh) = simulate_wave(wave, flow.facility.as_deref(), None)?; sections.push(format!( - "== {label} ==\n{}", + "== {label}{} ==\n{}", + if fresh { " (up to date)" } else { "" }, render_timeline(&trace, &written) )); reports.push(serde_json::json!({ diff --git a/crates/lab-cli/src/stamp.rs b/crates/lab-cli/src/stamp.rs new file mode 100644 index 0000000..6a9f7c7 --- /dev/null +++ b/crates/lab-cli/src/stamp.rs @@ -0,0 +1,112 @@ +//! Freshness stamps for the simulation flow. +//! +//! Each step of `lab simulate` / `lab scene` / `lab render` fingerprints +//! its inputs (file identity, size, and modification time) together with +//! the settings that shape its output, and writes the fingerprint beside +//! the output. Rerunning with nothing changed skips the step; touching +//! any input, or changing a setting that matters, regenerates it. The +//! fingerprint is a local cache key, never a portable artifact. + +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::path::{Path, PathBuf}; + +/// A fingerprint over input files and a settings string. Missing files +/// hash as absent rather than erroring: their appearance later changes +/// the fingerprint, which is exactly the point. +pub(crate) fn fingerprint(inputs: &[PathBuf], settings: &str) -> String { + let mut paths: Vec<&PathBuf> = inputs.iter().collect(); + paths.sort(); + let mut hasher = DefaultHasher::new(); + settings.hash(&mut hasher); + for path in paths { + path.hash(&mut hasher); + match std::fs::metadata(path) { + Ok(metadata) => { + metadata.len().hash(&mut hasher); + if let Ok(modified) = metadata.modified() { + modified.hash(&mut hasher); + } + } + Err(_) => "absent".hash(&mut hasher), + } + } + format!("{:016x}", hasher.finish()) +} + +/// True when the stamp file records exactly this fingerprint. +pub(crate) fn is_fresh(stamp: &Path, fingerprint: &str) -> bool { + std::fs::read_to_string(stamp) + .map(|recorded| recorded.trim() == fingerprint) + .unwrap_or(false) +} + +pub(crate) fn write(stamp: &Path, fingerprint: &str) { + // A failed stamp write only costs a rerun next time; never the run. + let _ = std::fs::write(stamp, fingerprint); +} + +/// The run documents a wave's simulation and scene derive from: the +/// coordination plan and every station document, or the STAR package's +/// manifest and run documents. +pub(crate) fn run_document_inputs(directory: &Path) -> Vec { + let mut inputs = Vec::new(); + let plan = directory.join(lab_runfmt::WORKCELL_PLAN_FILE); + if plan.is_file() { + inputs.push(plan); + let stations = directory.join("stations"); + if let Ok(entries) = std::fs::read_dir(&stations) { + for station in entries.flatten() { + if let Ok(documents) = std::fs::read_dir(station.path()) { + for document in documents.flatten() { + let path = document.path(); + if path + .extension() + .is_some_and(|extension| extension == "json") + { + inputs.push(path); + } + } + } + } + } + } else if let Ok(entries) = std::fs::read_dir(directory) { + for entry in entries.flatten() { + let path = entry.path(); + let name = entry.file_name().to_string_lossy().into_owned(); + if name == "automation_manifest.json" || name.ends_with(".star.json") { + inputs.push(path); + } + } + } + inputs +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_fingerprint_tracks_content_and_settings_changes() { + let directory = tempfile::tempdir().unwrap(); + let input = directory.path().join("plan.json"); + std::fs::write(&input, "one").unwrap(); + let inputs = vec![input.clone()]; + + let first = fingerprint(&inputs, "camera=dolly"); + assert_eq!(first, fingerprint(&inputs, "camera=dolly"), "stable"); + assert_ne!( + first, + fingerprint(&inputs, "camera=orbit"), + "settings participate" + ); + + // A size change definitely lands regardless of mtime resolution. + std::fs::write(&input, "changed").unwrap(); + assert_ne!(first, fingerprint(&inputs, "camera=dolly")); + + let stamp = directory.path().join(".step.stamp"); + write(&stamp, &first); + assert!(is_fresh(&stamp, &first)); + assert!(!is_fresh(&stamp, "something-else")); + } +} From c4a18d618764c46ae09d467cf82881224e8f39c1 Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Thu, 13 Aug 2026 21:20:53 -0600 Subject: [PATCH 14/17] Condense render timelines around motion Uniform speedup wastes most of a movie on motionless holds: a Golden Gate wave is nine hours of thermocycling around nine minutes of pipetting. lab render now warps time instead: motion plays at --speedup (default 60, slow enough to watch the head work), and every hold between motions compresses to --hold-seconds of footage, however long it really is. The warped trace lands beside the renders and the player runs it at speedup one; --uniform keeps real proportions. An animation render also owns its frames directory outright now, so stale frames from an earlier, longer cut cannot ride into the movie. --- crates/lab-cli/src/main.rs | 16 ++- crates/lab-cli/src/render.rs | 255 ++++++++++++++++++++++++++++++++++- 2 files changed, 263 insertions(+), 8 deletions(-) diff --git a/crates/lab-cli/src/main.rs b/crates/lab-cli/src/main.rs index 4ab8be7..f24125c 100644 --- a/crates/lab-cli/src/main.rs +++ b/crates/lab-cli/src/main.rs @@ -141,8 +141,10 @@ enum Command { /// Camera preset. #[arg(long, default_value = "dolly")] camera: String, - /// Simulated seconds per wall-clock second of footage. - #[arg(long, default_value_t = 600.0)] + /// Simulated seconds per footage second while something moves. + /// Holds between motions compress to --hold-seconds unless + /// --uniform keeps real proportions. + #[arg(long, default_value_t = 60.0)] speedup: f64, /// Frames per second of footage. #[arg(long, default_value_t = 24)] @@ -171,6 +173,12 @@ enum Command { /// one process, which already saturates the GPU. #[arg(long)] jobs: Option, + /// Footage seconds each motionless hold plays for. + #[arg(long, default_value_t = 2.0)] + hold_seconds: f64, + /// Keep time linear at --speedup instead of condensing holds. + #[arg(long)] + uniform: bool, }, /// Print resolved package metadata and source-module names. Metadata { @@ -273,6 +281,8 @@ fn run() -> Result<()> { out_dir, facility, jobs, + hold_seconds, + uniform, } => render::render( path, render::RenderOptions { @@ -286,6 +296,8 @@ fn run() -> Result<()> { out_dir, facility, jobs, + hold_seconds, + uniform, }, &output, ), diff --git a/crates/lab-cli/src/render.rs b/crates/lab-cli/src/render.rs index dfe9172..a4d4e29 100644 --- a/crates/lab-cli/src/render.rs +++ b/crates/lab-cli/src/render.rs @@ -28,6 +28,138 @@ pub(crate) struct RenderOptions { pub out_dir: Option, pub facility: Option, pub jobs: Option, + pub hold_seconds: f64, + pub uniform: bool, +} + +/// The spans of a run where something visibly moves on camera: a liquid +/// handler working its frames (the head glides program-long), and labware +/// traveling for two simulated seconds after each confirmed handoff. +fn motion_intervals(trace: &lab_runfmt::SimTraceDocument) -> Vec<(f64, f64)> { + use lab_runfmt::RunEvent; + let mut intervals: Vec<(f64, f64)> = Vec::new(); + let mut program_start: Option = None; + for timed in &trace.events { + match &timed.event { + RunEvent::Frame { x_mm: Some(_), .. } => { + if program_start.is_none() { + program_start = Some(timed.t); + } + } + RunEvent::NodeCompleted { .. } => { + if let Some(start) = program_start.take() { + intervals.push((start, timed.t)); + } + } + RunEvent::LabwareMoved { .. } => { + intervals.push((timed.t, timed.t + 2.0)); + } + _ => {} + } + } + if let Some(start) = program_start { + intervals.push((start, trace.summary.total_seconds)); + } + intervals.sort_by(|a, b| a.0.total_cmp(&b.0)); + // Merge overlaps so the warp is strictly increasing. + let mut merged: Vec<(f64, f64)> = Vec::new(); + for interval in intervals { + match merged.last_mut() { + Some(last) if interval.0 <= last.1 => last.1 = last.1.max(interval.1), + _ => merged.push(interval), + } + } + merged +} + +/// Warps simulated seconds onto footage seconds: motion plays at the +/// requested speedup, every hold between motions compresses to a fixed +/// beat. The result is a strictly increasing piecewise-linear map. +struct TimeWarp { + /// `(sim_start, sim_end, footage_start, footage_per_sim_second)` + segments: Vec<(f64, f64, f64, f64)>, + footage_end: f64, +} + +impl TimeWarp { + fn build(trace: &lab_runfmt::SimTraceDocument, options: &RenderOptions) -> Self { + let total = trace.summary.total_seconds; + let motions = motion_intervals(trace); + let motion_rate = 1.0 / options.speedup.max(1.0); + let mut segments = Vec::new(); + let mut sim_cursor = 0.0; + let mut footage_cursor = 0.0; + let push = |from: f64, + to: f64, + rate: f64, + footage: &mut f64, + segments: &mut Vec<(f64, f64, f64, f64)>| { + if to > from { + segments.push((from, to, *footage, rate)); + *footage += (to - from) * rate; + } + }; + for (start, end) in motions { + let hold = start - sim_cursor; + if hold > 0.0 { + // A hold always gets its beat, however long it really is. + let rate = options.hold_seconds / hold; + push(sim_cursor, start, rate, &mut footage_cursor, &mut segments); + } + push( + start, + end.min(total), + motion_rate, + &mut footage_cursor, + &mut segments, + ); + sim_cursor = end.min(total); + } + if sim_cursor < total { + let rate = options.hold_seconds / (total - sim_cursor); + push(sim_cursor, total, rate, &mut footage_cursor, &mut segments); + } + TimeWarp { + segments, + footage_end: footage_cursor, + } + } + + fn warp(&self, t: f64) -> f64 { + for (from, to, footage_start, rate) in &self.segments { + if t <= *to { + return footage_start + (t.max(*from) - from) * rate; + } + } + self.footage_end + } +} + +/// Writes the render-ready trace: event times in footage seconds, so the +/// player runs at speedup one and the camera spans the condensed length. +fn condensed_trace( + trace_path: &Path, + out_dir: &Path, + options: &RenderOptions, +) -> Result<(PathBuf, f64, Option)> { + let text = std::fs::read_to_string(trace_path) + .with_context(|| format!("failed to read {}", trace_path.display()))?; + let mut trace: lab_runfmt::SimTraceDocument = serde_json::from_str(&text) + .with_context(|| format!("failed to parse {}", trace_path.display()))?; + let warp = TimeWarp::build(&trace, options); + for timed in &mut trace.events { + timed.t = warp.warp(timed.t); + } + for window in &mut trace.summary.attention_windows { + window.from_seconds = warp.warp(window.from_seconds); + window.to_seconds = warp.warp(window.to_seconds); + } + trace.summary.total_seconds = warp.footage_end; + let still = options.still.map(|t| warp.warp(t)); + let path = out_dir.join("render-trace.json"); + std::fs::write(&path, serde_json::to_string_pretty(&trace)?) + .with_context(|| format!("failed to write {}", path.display()))?; + Ok((path, warp.footage_end, still)) } /// How many Blender processes render one wave. Previews are dominated by @@ -118,8 +250,15 @@ fn render_wave( // Skip Blender entirely when the documents and the settings that // shape the footage both match the last run. let settings = format!( - "camera={};speedup={};fps={};quality={};still={:?};hdri={:?}", - options.camera, options.speedup, options.fps, options.quality, options.still, options.hdri + "render-v2;camera={};speedup={};fps={};quality={};still={:?};hdri={:?};hold={};uniform={}", + options.camera, + options.speedup, + options.fps, + options.quality, + options.still, + options.hdri, + options.hold_seconds, + options.uniform ); let print = crate::stamp::fingerprint(&[scene_path.clone(), trace_path.clone()], &settings); let stamp_path = out_dir.join(".render.stamp"); @@ -133,6 +272,16 @@ fn render_wave( } let blender = find_blender(options.blender.as_deref())?; + // Condensed time is the default: motion at --speedup, holds squeezed + // to their beat. The warped trace lives beside the renders and the + // player runs it at speedup one; --uniform keeps real proportions. + let (script_trace, script_speedup, script_still, footage_seconds) = if options.uniform { + (trace_path.clone(), options.speedup, options.still, None) + } else { + let (path, footage_end, still) = condensed_trace(&trace_path, &out_dir, options)?; + (path, 1.0, still, Some(footage_end)) + }; + let script_path = out_dir.join("lab_blender.py"); std::fs::write(&script_path, PLAYER) .with_context(|| format!("failed to write {}", script_path.display()))?; @@ -150,18 +299,18 @@ fn render_wave( .arg("--scene") .arg(&scene_path) .arg("--trace") - .arg(&trace_path) + .arg(&script_trace) .arg("--out") .arg(&out_dir) .arg("--camera") .arg(&options.camera) .arg("--speedup") - .arg(options.speedup.to_string()) + .arg(script_speedup.to_string()) .arg("--fps") .arg(options.fps.to_string()) .arg("--quality") .arg(&options.quality); - if let Some(still) = options.still { + if let Some(still) = script_still { command.arg("--still").arg(still.to_string()); } if let Some(hdri) = &options.hdri { @@ -177,6 +326,16 @@ fn render_wave( command }; + // Stale frames from an earlier, longer cut would ride into the movie: + // an animation render owns its frames directory outright. + if options.still.is_none() { + let frames_dir = out_dir.join("frames"); + if frames_dir.is_dir() { + std::fs::remove_dir_all(&frames_dir) + .with_context(|| format!("failed to clear {}", frames_dir.display()))?; + } + } + let jobs = if options.still.is_some() { 1 } else { @@ -193,7 +352,11 @@ fn render_wave( } else { // Every process builds the identical timeline and renders its own // slice of the frame range into the shared frames directory. - let chunks = frame_chunks(footage_frames(&trace_path, options)?, jobs); + let frame_total = match footage_seconds { + Some(seconds) => (seconds * f64::from(options.fps)).ceil().max(2.0) as u32, + None => footage_frames(&trace_path, options)?, + }; + let chunks = frame_chunks(frame_total, jobs); println!( "rendering with {} across {} process(es)", blender.display(), @@ -303,6 +466,86 @@ pub(crate) fn render(directory: PathBuf, options: RenderOptions, output: &Output mod tests { use super::*; + #[test] + fn holds_compress_to_their_beat_and_motion_keeps_its_speed() { + use lab_runfmt::{ProgramExtent, RunEvent, SimSummary, SimTraceDocument, TimedEvent}; + let timed = |t: f64, event: RunEvent| TimedEvent { t, event }; + let trace = SimTraceDocument { + format: lab_runfmt::SIM_TRACE_FORMAT.to_string(), + plan: "plan.workcell.json".to_string(), + durations: "default-v0".to_string(), + events: vec![ + timed( + 0.0, + RunEvent::ProgramStarted { + station: "star-1".to_string(), + title: "assembly".to_string(), + extent: ProgramExtent::Frames { frames: 2 }, + }, + ), + timed( + 0.0, + RunEvent::Frame { + station: "star-1".to_string(), + index: 1, + description: "pick up".to_string(), + x_mm: Some(100.0), + y_mm: Some(200.0), + }, + ), + timed( + 600.0, + RunEvent::NodeCompleted { + id: "run".to_string(), + }, + ), + timed( + 700.0, + RunEvent::LabwareMoved { + labware: "plate".to_string(), + from: "star-1".to_string(), + to: "odtc-1".to_string(), + }, + ), + ], + summary: SimSummary { + total_seconds: 34_000.0, + ..SimSummary::default() + }, + }; + let options = RenderOptions { + camera: "dolly".to_string(), + speedup: 60.0, + fps: 24, + quality: "preview".to_string(), + still: None, + hdri: None, + blender: None, + out_dir: None, + facility: None, + jobs: None, + hold_seconds: 2.0, + uniform: false, + }; + let warp = TimeWarp::build(&trace, &options); + // Motion 0..600 at 60x = 10 s, hold 600..700 = 2 s, travel + // 700..702 at 60x, final hold to 34 000 s = 2 s. + assert!((warp.warp(600.0) - 10.0).abs() < 1e-9); + assert!((warp.warp(700.0) - 12.0).abs() < 1e-9); + assert!( + (warp.footage_end - (10.0 + 2.0 + 2.0 / 60.0 + 2.0)).abs() < 1e-9, + "nine hours of hold cost two seconds of footage: {}", + warp.footage_end + ); + // Strictly increasing across the whole run. + let mut previous = -1.0; + for t in [0.0, 1.0, 599.0, 650.0, 701.0, 5_000.0, 34_000.0] { + let footage = warp.warp(t); + assert!(footage > previous, "monotonic at t={t}"); + previous = footage; + } + } + #[test] fn frame_chunks_cover_the_range_exactly_once() { assert_eq!(frame_chunks(10, 4), [(1, 3), (4, 6), (7, 8), (9, 10)]); From 660e45f2a2b450e197dbdc782a06a4c8f5416466 Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Fri, 14 Aug 2026 08:18:34 -0600 Subject: [PATCH 15/17] Decouple lab render and make freshness survive any working directory lab render only renders: it consumes the outputs of lab simulate and lab scene and names whichever is missing, so iterating on a simulation never costs frames. Two freshness bugs made the pipeline look coupled anyway: fingerprints hashed path strings as typed, so the same files went stale whenever the working directory changed, and a re-run step rewrote byte-identical output with a fresh mtime, cascading staleness downstream. Fingerprints now canonicalize their paths and every step writes only when its bytes actually changed. --- crates/lab-cli/src/main.rs | 6 +-- crates/lab-cli/src/render.rs | 30 +++++++------- crates/lab-cli/src/scene.rs | 6 +-- crates/lab-cli/src/simulate.rs | 6 +-- crates/lab-cli/src/stamp.rs | 49 +++++++++++++++++++++- crates/lab-cli/tests/project_workflow.rs | 53 ++++++++++++++++++++++++ examples/golden-gate/README.md | 6 ++- 7 files changed, 131 insertions(+), 25 deletions(-) diff --git a/crates/lab-cli/src/main.rs b/crates/lab-cli/src/main.rs index f24125c..75417b1 100644 --- a/crates/lab-cli/src/main.rs +++ b/crates/lab-cli/src/main.rs @@ -133,9 +133,9 @@ enum Command { /// Render the simulated run as photographic frames (and a movie when /// ffmpeg is present) through a headless Blender. Render { - /// A run directory or package directory. The simulation and the - /// animated scene regenerate first, so this one command is the - /// whole flow. Defaults to the current directory. + /// A run directory or package directory holding the outputs of + /// `lab simulate` and `lab scene`. Rendering never regenerates + /// them. Defaults to the current directory. #[arg(default_value = ".")] path: PathBuf, /// Camera preset. diff --git a/crates/lab-cli/src/render.rs b/crates/lab-cli/src/render.rs index a4d4e29..675e282 100644 --- a/crates/lab-cli/src/render.rs +++ b/crates/lab-cli/src/render.rs @@ -242,7 +242,19 @@ fn render_wave( out_dir_override: Option, ) -> Result<(PathBuf, Option, bool)> { let scene_path = directory.join("scene.json"); + if !scene_path.is_file() { + bail!( + "no scene at {}; run `lab scene` on this package first", + scene_path.display() + ); + } let trace_path = directory.join("sim-trace.json"); + if !trace_path.is_file() { + bail!( + "no trace at {}; run `lab simulate` on this package first", + trace_path.display() + ); + } let out_dir = out_dir_override.unwrap_or_else(|| directory.join("renders")); std::fs::create_dir_all(&out_dir) .with_context(|| format!("failed to create {}", out_dir.display()))?; @@ -409,8 +421,10 @@ fn render_wave( Ok((out_dir, movie, false)) } -/// The whole cinematic flow: simulate, build the animated scene, render. -/// Every step is idempotent, so `lab render` alone is always enough. +/// Renders footage from the scene and trace that `lab scene` and +/// `lab simulate` produced; it never regenerates them, so iterating on a +/// simulation costs no frames. A stale or missing input is named, with +/// the command that refreshes it. pub(crate) fn render(directory: PathBuf, options: RenderOptions, output: &Output) -> Result<()> { let flow = crate::flow::resolve(&directory, options.facility.clone())?; let single = flow.waves.len() == 1; @@ -419,18 +433,6 @@ pub(crate) fn render(directory: PathBuf, options: RenderOptions, output: &Output let mut reports = Vec::new(); for wave in &flow.waves { let label = crate::flow::wave_label(wave); - let (_, _, sim_fresh) = - crate::simulate::simulate_wave(wave, flow.facility.as_deref(), None)?; - println!( - "== {label}: simulate{} ==", - if sim_fresh { " (up to date)" } else { "" } - ); - let (_, scene_fresh) = - crate::scene::generate_for(wave, flow.facility.as_deref(), true, None)?; - println!( - "== {label}: scene{} ==", - if scene_fresh { " (up to date)" } else { "" } - ); let out_override = if single { options.out_dir.clone() } else { diff --git a/crates/lab-cli/src/scene.rs b/crates/lab-cli/src/scene.rs index 28e2960..0907f25 100644 --- a/crates/lab-cli/src/scene.rs +++ b/crates/lab-cli/src/scene.rs @@ -226,17 +226,17 @@ pub(crate) fn generate_for( .with_context(|| format!("failed to create {}", out_dir.display()))?; let scene_path = out_dir.join("scene.json"); - std::fs::write(&scene_path, serde_json::to_string_pretty(&scene)?) + crate::stamp::write_if_changed(&scene_path, &serde_json::to_string_pretty(&scene)?) .with_context(|| format!("failed to write {}", scene_path.display()))?; let gltf_path = out_dir.join("scene.gltf"); - std::fs::write(&gltf_path, render_gltf(&scene)) + crate::stamp::write_if_changed(&gltf_path, &render_gltf(&scene)) .with_context(|| format!("failed to write {}", gltf_path.display()))?; let usda_path = out_dir.join("scene.usda"); let usda = match &trace { Some(trace) => lab_scene::animate::render_usda_animated(&scene, trace)?, None => render_usda(&scene), }; - std::fs::write(&usda_path, usda) + crate::stamp::write_if_changed(&usda_path, &usda) .with_context(|| format!("failed to write {}", usda_path.display()))?; crate::stamp::write(&stamp_path, &print); diff --git a/crates/lab-cli/src/simulate.rs b/crates/lab-cli/src/simulate.rs index 19cfac0..5bd26f0 100644 --- a/crates/lab-cli/src/simulate.rs +++ b/crates/lab-cli/src/simulate.rs @@ -20,8 +20,8 @@ use crate::Output; /// The trace file a simulation writes beside the package it simulated. const TRACE_FILE: &str = "sim-trace.json"; -/// Simulates one run directory and writes its trace beside the plan. -/// The idempotent core `lab render` reuses. +/// Simulates one run directory and writes its trace beside the plan, +/// touching the file only when its content actually changed. pub(crate) fn simulate_wave( directory: &Path, facility_path: Option<&Path>, @@ -78,7 +78,7 @@ pub(crate) fn simulate_wave( let custom_destination = trace_path.is_some(); let trace_path = trace_path.unwrap_or_else(|| directory.join(TRACE_FILE)); let text = serde_json::to_string_pretty(&trace)?; - std::fs::write(&trace_path, text) + crate::stamp::write_if_changed(&trace_path, &text) .with_context(|| format!("failed to write {}", trace_path.display()))?; if !custom_destination { crate::stamp::write(&stamp_path, &print); diff --git a/crates/lab-cli/src/stamp.rs b/crates/lab-cli/src/stamp.rs index 6a9f7c7..ea7a283 100644 --- a/crates/lab-cli/src/stamp.rs +++ b/crates/lab-cli/src/stamp.rs @@ -14,7 +14,12 @@ use std::path::{Path, PathBuf}; /// hash as absent rather than erroring: their appearance later changes /// the fingerprint, which is exactly the point. pub(crate) fn fingerprint(inputs: &[PathBuf], settings: &str) -> String { - let mut paths: Vec<&PathBuf> = inputs.iter().collect(); + // Canonical paths, so the fingerprint is the same file seen from any + // working directory. + let mut paths: Vec = inputs + .iter() + .map(|path| path.canonicalize().unwrap_or_else(|_| path.clone())) + .collect(); paths.sort(); let mut hasher = DefaultHasher::new(); settings.hash(&mut hasher); @@ -45,6 +50,19 @@ pub(crate) fn write(stamp: &Path, fingerprint: &str) { let _ = std::fs::write(stamp, fingerprint); } +/// Writes only when the bytes differ, preserving the modification time of +/// an unchanged file. A step that re-runs but produces identical output +/// then leaves every downstream step fresh. +pub(crate) fn write_if_changed(path: &Path, text: &str) -> std::io::Result { + if let Ok(existing) = std::fs::read_to_string(path) + && existing == text + { + return Ok(false); + } + std::fs::write(path, text)?; + Ok(true) +} + /// The run documents a wave's simulation and scene derive from: the /// coordination plan and every station document, or the STAR package's /// manifest and run documents. @@ -109,4 +127,33 @@ mod tests { assert!(is_fresh(&stamp, &first)); assert!(!is_fresh(&stamp, "something-else")); } + + #[test] + fn fingerprints_and_quiet_writes_survive_working_directory_games() { + let directory = tempfile::tempdir().unwrap(); + let input = directory.path().join("plan.json"); + std::fs::write(&input, "content").unwrap(); + // The same file through an unnormalized path fingerprints alike. + let twisted = directory.path().join("subdir/../plan.json"); + std::fs::create_dir_all(directory.path().join("subdir")).unwrap(); + assert_eq!( + fingerprint(std::slice::from_ref(&input), "s"), + fingerprint(&[twisted], "s"), + "canonicalization erases the spelling of the path" + ); + + let output = directory.path().join("out.json"); + assert!(write_if_changed(&output, "same").unwrap()); + let modified = std::fs::metadata(&output).unwrap().modified().unwrap(); + assert!( + !write_if_changed(&output, "same").unwrap(), + "identical bytes are not rewritten" + ); + assert_eq!( + std::fs::metadata(&output).unwrap().modified().unwrap(), + modified, + "the modification time survives" + ); + assert!(write_if_changed(&output, "different").unwrap()); + } } diff --git a/crates/lab-cli/tests/project_workflow.rs b/crates/lab-cli/tests/project_workflow.rs index e4fc570..27ebe01 100644 --- a/crates/lab-cli/tests/project_workflow.rs +++ b/crates/lab-cli/tests/project_workflow.rs @@ -1097,3 +1097,56 @@ fn the_zero_argument_flow_simulates_a_package_from_its_root() { std::fs::remove_dir_all(&package).unwrap(); } + +#[test] +fn render_refuses_to_regenerate_what_simulate_and_scene_own() { + let example = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../examples/golden-gate") + .canonicalize() + .unwrap(); + let out_dir = std::env::temp_dir().join(format!( + "lab-golden-gate-decoupled-{}-{}", + std::process::id(), + line!() + )); + if out_dir.exists() { + std::fs::remove_dir_all(&out_dir).unwrap(); + } + let build = Command::new(env!("CARGO_BIN_EXE_lab")) + .args([ + "build", + example.to_str().unwrap(), + "--target", + "workcell-star", + "--out-dir", + out_dir.to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!(build.status.success()); + let wave = out_dir.join("workcell-star/wave-001"); + + // No scene yet: render names the command that makes one, and does not + // run it itself. + let simulated = Command::new(env!("CARGO_BIN_EXE_lab")) + .args(["simulate", wave.to_str().unwrap()]) + .output() + .unwrap(); + assert!(simulated.status.success()); + let premature = Command::new(env!("CARGO_BIN_EXE_lab")) + .args(["render", wave.to_str().unwrap()]) + .output() + .unwrap(); + assert!(!premature.status.success()); + let stderr = String::from_utf8_lossy(&premature.stderr); + assert!( + stderr.contains("lab scene"), + "the missing step is named: {stderr}" + ); + assert!( + !wave.join("scene.json").exists(), + "render regenerated nothing" + ); + + std::fs::remove_dir_all(&out_dir).unwrap(); +} diff --git a/examples/golden-gate/README.md b/examples/golden-gate/README.md index ffbf1eb..2fae5b4 100644 --- a/examples/golden-gate/README.md +++ b/examples/golden-gate/README.md @@ -45,9 +45,13 @@ zero-argument: ```bash lab simulate # timeline, attended vs walk-away, sim-trace.json per wave -lab render # simulate + animated scene + Blender frames, per wave +lab scene # scene.json + glTF + USD per wave (--animated adds the timeline) +lab render # Blender frames and a movie from those outputs, per wave ``` +Each command skips work whose inputs have not changed, so rerunning any +of them costs nothing until the build, the facility, or a setting moves. + `facility.toml` at the package root describes the room the simulation runs in: station positions, storage, and transport times. `lab simulate` and `lab render` pick it up by convention; `--facility` names another one. From e49c1f664c2877800633f266213475ee1fa52063 Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Sat, 15 Aug 2026 11:49:12 -0600 Subject: [PATCH 16/17] Add Isaac robot learning and C3 compute prototype --- .gitignore | 2 + Cargo.lock | 11 + Cargo.toml | 2 + crates/lab-cli/Cargo.toml | 1 + crates/lab-cli/README.md | 15 + crates/lab-cli/src/compute.rs | 141 + crates/lab-cli/src/main.rs | 93 + crates/lab-cli/src/robot_task.rs | 243 ++ crates/lab-cli/tests/project_workflow.rs | 85 + crates/lab-compute/Cargo.toml | 19 + crates/lab-compute/README.md | 20 + crates/lab-compute/src/c3.rs | 373 ++ crates/lab-compute/src/lib.rs | 140 + crates/lab-runfmt/src/lib.rs | 5 + crates/lab-runfmt/src/robot_task.rs | 131 + docs/README.md | 4 + docs/language/README.md | 10 +- ...-robot-learning-is-a-physics-projection.md | 65 + ...0038-c3-is-the-primary-compute-provider.md | 69 + examples/golden-gate/README.md | 13 + integrations/isaac-lab/.c3 | 12 + integrations/isaac-lab/README.md | 124 + integrations/isaac-lab/c3/README.md | 25 + integrations/isaac-lab/c3/probe.py | 110 + integrations/isaac-lab/c3/probe.sh | 11 + .../isaac-lab/c3/runtime/pyproject.toml | 37 + integrations/isaac-lab/c3/runtime/uv.lock | 3087 +++++++++++++++++ .../golden-gate-plate-transfer.binding.toml | 43 + integrations/isaac-lab/pyproject.toml | 39 + .../isaac-lab/src/lab_isaac/__init__.py | 5 + integrations/isaac-lab/src/lab_isaac/cli.py | 73 + .../isaac-lab/src/lab_isaac/contract.py | 410 +++ .../isaac-lab/src/lab_isaac/isaac_env.py | 213 ++ integrations/isaac-lab/src/lab_isaac/py.typed | 1 + .../tests/fixtures/plan.workcell.json | 26 + .../tests/fixtures/robot-tasks/task.json | 26 + .../isaac-lab/tests/fixtures/scene.json | 28 + integrations/isaac-lab/tests/test_contract.py | 56 + .../isaac-lab/tests/test_isaac_env.py | 15 + integrations/isaac-lab/uv.lock | 307 ++ 40 files changed, 6088 insertions(+), 2 deletions(-) create mode 100644 crates/lab-cli/src/compute.rs create mode 100644 crates/lab-cli/src/robot_task.rs create mode 100644 crates/lab-compute/Cargo.toml create mode 100644 crates/lab-compute/README.md create mode 100644 crates/lab-compute/src/c3.rs create mode 100644 crates/lab-compute/src/lib.rs create mode 100644 crates/lab-runfmt/src/robot_task.rs create mode 100644 docs/language/decisions/0037-robot-learning-is-a-physics-projection.md create mode 100644 docs/language/decisions/0038-c3-is-the-primary-compute-provider.md create mode 100644 integrations/isaac-lab/.c3 create mode 100644 integrations/isaac-lab/README.md create mode 100644 integrations/isaac-lab/c3/README.md create mode 100644 integrations/isaac-lab/c3/probe.py create mode 100755 integrations/isaac-lab/c3/probe.sh create mode 100644 integrations/isaac-lab/c3/runtime/pyproject.toml create mode 100644 integrations/isaac-lab/c3/runtime/uv.lock create mode 100644 integrations/isaac-lab/examples/golden-gate-plate-transfer.binding.toml create mode 100644 integrations/isaac-lab/pyproject.toml create mode 100644 integrations/isaac-lab/src/lab_isaac/__init__.py create mode 100644 integrations/isaac-lab/src/lab_isaac/cli.py create mode 100644 integrations/isaac-lab/src/lab_isaac/contract.py create mode 100644 integrations/isaac-lab/src/lab_isaac/isaac_env.py create mode 100644 integrations/isaac-lab/src/lab_isaac/py.typed create mode 100644 integrations/isaac-lab/tests/fixtures/plan.workcell.json create mode 100644 integrations/isaac-lab/tests/fixtures/robot-tasks/task.json create mode 100644 integrations/isaac-lab/tests/fixtures/scene.json create mode 100644 integrations/isaac-lab/tests/test_contract.py create mode 100644 integrations/isaac-lab/tests/test_isaac_env.py create mode 100644 integrations/isaac-lab/uv.lock diff --git a/.gitignore b/.gitignore index 368b5fc..b5ca555 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ /.cargo/ .lab/ .venv/ +.env +.c3.local *.rs.bk .DS_Store crates/lab-python/python/lab/_native.* diff --git a/Cargo.lock b/Cargo.lock index d66014f..6a5758e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1606,6 +1606,7 @@ dependencies = [ "clap", "flate2", "lab-compiler", + "lab-compute", "lab-package", "lab-project", "lab-runfmt", @@ -1645,6 +1646,16 @@ dependencies = [ "toml", ] +[[package]] +name = "lab-compute" +version = "0.1.2" +dependencies = [ + "serde", + "serde_json", + "tempfile", + "thiserror", +] + [[package]] name = "lab-ide" version = "0.1.2" diff --git a/Cargo.toml b/Cargo.toml index 6ba14eb..a98dedf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = "2" members = [ "crates/lab-cli", + "crates/lab-compute", "crates/lab-compiler", "crates/lab-ide", "crates/lab-ide-wasm", @@ -63,6 +64,7 @@ pliron = { git = "https://github.com/pliron-org/pliron", rev = "a4390be145f151b1 # never links the native HID library; a runner re-enables `hid`. byonoy-hid = { version = "0.1.0", default-features = false } lab-compiler = { path = "crates/lab-compiler", version = "0.1.2" } +lab-compute = { path = "crates/lab-compute", version = "0.1.2" } # Default features stay off at the workspace level so the compiler's pure # protocol use never links libusb; the CLI runner re-enables `usb`. hamilton-star = { version = "0.1.0", default-features = false } diff --git a/crates/lab-cli/Cargo.toml b/crates/lab-cli/Cargo.toml index f4d5e28..30522a8 100644 --- a/crates/lab-cli/Cargo.toml +++ b/crates/lab-cli/Cargo.toml @@ -16,6 +16,7 @@ anyhow.workspace = true clap.workspace = true flate2 = "1.1.9" lab-compiler.workspace = true +lab-compute.workspace = true lab-runfmt.workspace = true # The runner needs the live USB transport the workspace dependency keeps # off by default. diff --git a/crates/lab-cli/README.md b/crates/lab-cli/README.md index 60e53bb..30dae20 100644 --- a/crates/lab-cli/README.md +++ b/crates/lab-cli/README.md @@ -68,4 +68,19 @@ lab metadata --json lab check --json ``` +Remote robot-learning compute is C3-first. A local `.env` may hold +`C3_API_KEY`; it is ignored by Git and read as data rather than sourced as a +shell script. The doctor is read-only: it validates authentication and the +current L40-class catalog without submitting a job. + +```sh +lab compute doctor +lab compute list +``` + +Isaac Sim requires RTX hardware, so the doctor recognizes C3's L40/L40S class +and does not present A100 or H100 capacity as Isaac-compatible. Actual training +commands remain absent until the tracked C3 capability gate and a real PPO +runner have passed. + Path dependencies may optionally carry a semver requirement, which is checked against the dependency manifest. Registry dependencies remain explicitly unsupported and fail closed; adding them requires a registry protocol and integrity model rather than silent fallback. Workflow execution commands will be added only when the durable runtime has real run semantics. diff --git a/crates/lab-cli/src/compute.rs b/crates/lab-cli/src/compute.rs new file mode 100644 index 0000000..c050f34 --- /dev/null +++ b/crates/lab-cli/src/compute.rs @@ -0,0 +1,141 @@ +//! User-facing read-only checks for Lab's C3-first compute boundary. + +use std::{env, ffi::OsString, path::PathBuf}; + +use anyhow::{Context, Result}; +use lab_compute::{ + ComputeProvider, HardwareCatalog, HardwareProfile, + c3::{C3Provider, dotenv_value}, +}; +use serde::Serialize; + +use crate::Output; + +#[derive(Debug, Serialize)] +struct ComputeDoctorReport { + provider: &'static str, + authenticated: bool, + catalog_profiles: usize, + isaac_compatible_profiles: Vec, +} + +fn provider(env_file: PathBuf) -> Result { + let program = env::var_os("LAB_C3_BIN").unwrap_or_else(|| OsString::from("c3")); + let mut provider = C3Provider::new(program); + if env_file.exists() { + let api_key = dotenv_value(&env_file, "C3_API_KEY")? + .with_context(|| format!("{} has no non-empty C3_API_KEY", env_file.display()))?; + provider = provider.with_api_key(api_key); + } + Ok(provider) +} + +fn isaac_compatible(profile: &HardwareProfile) -> bool { + profile.available + && profile.accelerator == "cuda" + && profile + .accelerator_memory_gb + .is_some_and(|memory| memory >= 16) + && profile.selector.to_ascii_lowercase().starts_with("l40") +} + +pub(crate) fn doctor(env_file: PathBuf, output: &Output) -> Result<()> { + let provider = provider(env_file)?; + provider + .authenticate() + .context("C3 authentication failed")?; + let catalog = provider + .hardware_catalog() + .context("failed to read C3 hardware catalog")?; + let compatible = catalog + .profiles + .iter() + .filter(|profile| isaac_compatible(profile)) + .cloned() + .collect::>(); + if compatible.is_empty() { + anyhow::bail!( + "C3 authentication succeeded, but no L40-class Isaac-compatible profile is catalogued" + ); + } + let names = compatible + .iter() + .map(|profile| profile.display_name.as_str()) + .collect::>() + .join(", "); + output.success( + "compute-doctor", + ComputeDoctorReport { + provider: provider.name(), + authenticated: true, + catalog_profiles: catalog.profiles.len(), + isaac_compatible_profiles: compatible, + }, + format!( + "C3 authentication and catalog access passed\n Isaac-compatible: {names}\n no job was submitted" + ), + ) +} + +pub(crate) fn list(env_file: PathBuf, output: &Output) -> Result<()> { + let provider = provider(env_file)?; + let catalog = provider + .hardware_catalog() + .context("failed to read C3 hardware catalog")?; + let human = catalog_table(&catalog); + output.success("compute-catalog", catalog, human) +} + +fn catalog_table(catalog: &HardwareCatalog) -> String { + let mut lines = vec![format!("{} hardware", catalog.provider.to_uppercase())]; + for profile in &catalog.profiles { + let memory = profile + .accelerator_memory_gb + .map(|value| format!("{value} GB")) + .unwrap_or_else(|| "n/a".to_owned()); + let price = profile + .price_per_hour + .zip(profile.price_currency.as_deref()) + .map(|(value, currency)| format!("{value:.3} {currency}/h")) + .unwrap_or_else(|| "price unavailable".to_owned()); + let availability = profile.availability.as_deref().unwrap_or("unknown"); + let isaac = if isaac_compatible(profile) { + " [Isaac-compatible class]" + } else { + "" + }; + lines.push(format!( + " {:<10} {:<20} {:<8} {:<18} {}{}", + profile.selector, profile.display_name, memory, price, availability, isaac + )); + } + lines.join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_l40_cuda_profiles_are_accepted_for_isaac() { + let profile = |selector: &str, accelerator: &str, memory| HardwareProfile { + selector: selector.to_owned(), + display_name: selector.to_owned(), + accelerator: accelerator.to_owned(), + accelerator_count: 1, + accelerator_memory_gb: memory, + available: true, + availability: Some("high".to_owned()), + price_per_hour: None, + price_currency: None, + }; + assert!(isaac_compatible(&profile("l40", "cuda", Some(48)))); + assert!(isaac_compatible(&profile("l40s", "cuda", Some(48)))); + assert!(!isaac_compatible(&profile("a100", "cuda", Some(80)))); + assert!(!isaac_compatible(&profile("h100", "cuda", Some(80)))); + assert!(!isaac_compatible(&profile("l40", "none", Some(48)))); + let mut unavailable = profile("l40", "cuda", Some(48)); + unavailable.available = false; + assert!(!isaac_compatible(&unavailable)); + } +} diff --git a/crates/lab-cli/src/main.rs b/crates/lab-cli/src/main.rs index 75417b1..d1e2dd9 100644 --- a/crates/lab-cli/src/main.rs +++ b/crates/lab-cli/src/main.rs @@ -1,6 +1,8 @@ mod commands; +mod compute; mod flow; mod render; +mod robot_task; mod run; mod scene; mod simulate; @@ -130,6 +132,16 @@ enum Command { #[arg(long)] animated: bool, }, + /// Inspect and operate finite remote compute jobs. + Compute { + #[command(subcommand)] + command: ComputeCommand, + }, + /// Project, train, evaluate, and deploy laboratory robots. + Robot { + #[command(subcommand)] + command: RobotCommand, + }, /// Render the simulated run as photographic frames (and a movie when /// ffmpeg is present) through a headless Blender. Render { @@ -194,6 +206,40 @@ enum Command { }, } +#[derive(Debug, Subcommand)] +enum ComputeCommand { + /// Verify C3 authentication and Isaac-compatible capacity without submitting a job. + Doctor { + /// Dotenv file holding C3_API_KEY; ignored when it does not exist. + #[arg(long, default_value = ".env")] + env_file: PathBuf, + }, + /// Show C3's current public hardware catalog. + List { + /// Dotenv file holding C3_API_KEY; ignored when it does not exist. + #[arg(long, default_value = ".env")] + env_file: PathBuf, + }, +} + +#[derive(Debug, Subcommand)] +enum RobotCommand { + /// Project one workcell handoff into a backend-neutral robot task. + Task { + /// A workcell wave holding plan.workcell.json and scene.json. + path: PathBuf, + /// The exact handoff node identity to project. + #[arg(long)] + node: String, + /// Semantic scene to validate; defaults to scene.json in the wave. + #[arg(long)] + scene: Option, + /// Output file; defaults to robot-tasks/.json in the wave. + #[arg(long)] + out: Option, + }, +} + struct Output { json: bool, } @@ -269,6 +315,18 @@ fn run() -> Result<()> { facility, animated, } => scene::scene(path, out_dir, facility, animated, &output), + Command::Compute { command } => match command { + ComputeCommand::Doctor { env_file } => compute::doctor(env_file, &output), + ComputeCommand::List { env_file } => compute::list(env_file, &output), + }, + Command::Robot { command } => match command { + RobotCommand::Task { + path, + node, + scene, + out, + } => robot_task::robot_task(path, node, scene, out, &output), + }, Command::Render { path, camera, @@ -346,6 +404,41 @@ mod tests { assert!(cli.json); } + #[test] + fn parses_robot_task_command() { + let cli = Cli::try_parse_from([ + "lab", "robot", "task", "wave-001", "--node", "handoff", "--json", + ]) + .unwrap(); + assert!(cli.json); + assert!(matches!( + cli.command, + Command::Robot { + command: RobotCommand::Task { path, node, scene: None, out: None } + } if path.as_path() == std::path::Path::new("wave-001") && node == "handoff" + )); + } + + #[test] + fn parses_compute_doctor_command() { + let cli = Cli::try_parse_from([ + "lab", + "compute", + "doctor", + "--env-file", + "credentials.env", + "--json", + ]) + .unwrap(); + assert!(cli.json); + assert!(matches!( + cli.command, + Command::Compute { + command: ComputeCommand::Doctor { env_file } + } if env_file.as_path() == std::path::Path::new("credentials.env") + )); + } + #[test] fn parses_update_check_flag() { let cli = Cli::try_parse_from(["lab", "update", "--check"]).unwrap(); diff --git a/crates/lab-cli/src/robot_task.rs b/crates/lab-cli/src/robot_task.rs new file mode 100644 index 0000000..2595ea7 --- /dev/null +++ b/crates/lab-cli/src/robot_task.rs @@ -0,0 +1,243 @@ +//! Projection of one reviewed workcell handoff into `lab.robot-task.v0`. + +use std::path::{Component, Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use lab_runfmt::{ + ROBOT_TASK_FORMAT, RobotTaskAction, RobotTaskCompletion, RobotTaskDocument, RobotTaskEndpoint, + RobotTaskObject, WORKCELL_PLAN_FILE, WorkcellAction, +}; +use lab_scene::{Scene, Semantic}; +use serde::Serialize; + +use crate::Output; + +#[derive(Serialize)] +struct RobotTaskReport { + id: String, + object: String, + source: String, + destination: String, + task: String, +} + +fn safe_file_stem(id: &str) -> String { + id.chars() + .map(|character| { + if character.is_ascii_alphanumeric() || matches!(character, '-' | '_') { + character + } else { + '-' + } + }) + .collect() +} + +fn document_reference(path: &Path, artifact_directory: &Path) -> String { + let target = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + let base = artifact_directory + .canonicalize() + .unwrap_or_else(|_| artifact_directory.to_path_buf()); + let target_components: Vec> = target.components().collect(); + let base_components: Vec> = base.components().collect(); + let common = target_components + .iter() + .zip(&base_components) + .take_while(|(left, right)| left == right) + .count(); + if common == 0 { + return target.to_string_lossy().into_owned(); + } + let mut reference = PathBuf::new(); + for _ in common..base_components.len() { + reference.push(".."); + } + for component in &target_components[common..] { + reference.push(component.as_os_str()); + } + if reference.as_os_str().is_empty() { + ".".to_string() + } else { + reference.to_string_lossy().into_owned() + } +} + +fn load_scene(path: &Path) -> Result { + let text = std::fs::read_to_string(path).with_context(|| { + format!( + "no semantic scene at {}; run `lab scene` on this wave first", + path.display() + ) + })?; + let scene: Scene = serde_json::from_str(&text) + .with_context(|| format!("failed to parse {}", path.display()))?; + if scene.format != lab_scene::scene::SCENE_FORMAT { + bail!( + "{} declares format '{}'; this reader expects '{}'", + path.display(), + scene.format, + lab_scene::scene::SCENE_FORMAT + ); + } + Ok(scene) +} + +fn validate_scene_node( + scene: &Scene, + id: &str, + expected: &str, + accepts: impl Fn(&Semantic) -> bool, +) -> Result<()> { + let mut identities = 0usize; + let mut matching = 0usize; + scene.root.walk(&mut |node, _| { + if node.id == id { + identities += 1; + if accepts(&node.semantic) { + matching += 1; + } + } + }); + match (identities, matching) { + (0, _) => bail!("scene has no node '{id}' for the task's {expected}"), + (_, 0) => bail!("scene node '{id}' is not a {expected}"), + (_, 1) => Ok(()), + (_, count) => { + bail!("scene has {count} {expected} nodes named '{id}'; task identities must be unique") + } + } +} + +pub(crate) fn robot_task( + directory: PathBuf, + node_id: String, + scene_path: Option, + out_path: Option, + output: &Output, +) -> Result<()> { + if !lab_runtime::workcell::is_workcell_directory(&directory) { + bail!( + "{} is not a workcell wave: no {}", + directory.display(), + WORKCELL_PLAN_FILE + ); + } + let plan = lab_runfmt::load_workcell_plan(&directory)?; + let mut matching_nodes = plan.nodes.iter().filter(|node| node.id == node_id); + let node = matching_nodes + .next() + .with_context(|| format!("workcell plan has no node '{node_id}'"))?; + if matching_nodes.next().is_some() { + bail!("workcell plan has more than one node named '{node_id}'"); + } + let WorkcellAction::Handoff { + from, + to, + labware, + instructions, + } = &node.action + else { + bail!( + "workcell node '{}' is not a handoff and cannot become a robot transfer task", + node.id + ); + }; + + let scene_path = scene_path.unwrap_or_else(|| directory.join("scene.json")); + let scene = load_scene(&scene_path)?; + validate_scene_node(&scene, from, "station", |semantic| { + matches!(semantic, Semantic::Station { .. }) + })?; + validate_scene_node(&scene, to, "station", |semantic| { + matches!(semantic, Semantic::Station { .. }) + })?; + validate_scene_node(&scene, labware, "labware object", |semantic| { + matches!(semantic, Semantic::Labware { .. }) + })?; + + let out_path = out_path.unwrap_or_else(|| { + directory + .join("robot-tasks") + .join(format!("{}.json", safe_file_stem(&node.id))) + }); + if let Some(parent) = out_path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + let artifact_directory = out_path.parent().unwrap_or_else(|| Path::new(".")); + let document = RobotTaskDocument { + format: ROBOT_TASK_FORMAT.to_string(), + id: node.id.clone(), + plan: document_reference(&directory.join(WORKCELL_PLAN_FILE), artifact_directory), + scene: document_reference(&scene_path, artifact_directory), + after: node.after.clone(), + action: RobotTaskAction::Transfer { + object: RobotTaskObject { + labware: labware.clone(), + scene_node: labware.clone(), + }, + source: RobotTaskEndpoint { + station: from.clone(), + scene_node: from.clone(), + }, + destination: RobotTaskEndpoint { + station: to.clone(), + scene_node: to.clone(), + }, + instructions: instructions.clone(), + completion: RobotTaskCompletion { + relation: "object-at-station".to_string(), + object: labware.clone(), + target: to.clone(), + }, + }, + }; + let text = format!("{}\n", serde_json::to_string_pretty(&document)?); + crate::stamp::write_if_changed(&out_path, &text) + .with_context(|| format!("failed to write {}", out_path.display()))?; + + let report = RobotTaskReport { + id: node.id.clone(), + object: labware.clone(), + source: from.clone(), + destination: to.clone(), + task: out_path.display().to_string(), + }; + output.success( + "robot-task", + &report, + format!( + "robot task '{}': {} from {} to {}\n {}", + report.id, + report.object, + report.source, + report.destination, + out_path.display() + ), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn node_id_becomes_a_safe_file_name() { + assert_eq!( + safe_file_stem("assembly_thermocycle.to/odtc-1"), + "assembly_thermocycle-to-odtc-1" + ); + } + + #[test] + fn document_paths_are_relative_to_the_artifact() { + let directory = tempfile::tempdir().unwrap(); + let wave = directory.path().join("wave-001"); + let tasks = wave.join("robot-tasks"); + std::fs::create_dir_all(&tasks).unwrap(); + let scene = wave.join("scene.json"); + std::fs::write(&scene, "{}").unwrap(); + + assert_eq!(document_reference(&scene, &tasks), "../scene.json"); + } +} diff --git a/crates/lab-cli/tests/project_workflow.rs b/crates/lab-cli/tests/project_workflow.rs index 27ebe01..5660b17 100644 --- a/crates/lab-cli/tests/project_workflow.rs +++ b/crates/lab-cli/tests/project_workflow.rs @@ -740,6 +740,91 @@ fn a_built_wave_renders_as_a_scene_with_exact_well_positions() { std::fs::remove_dir_all(&out_dir).unwrap(); } +#[test] +fn a_reviewed_handoff_projects_to_a_scene_checked_robot_task() { + let example = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../examples/golden-gate") + .canonicalize() + .unwrap(); + let out_dir = std::env::temp_dir().join(format!( + "lab-golden-gate-robot-task-{}-{}", + std::process::id(), + line!() + )); + if out_dir.exists() { + std::fs::remove_dir_all(&out_dir).unwrap(); + } + + let built = Command::new(env!("CARGO_BIN_EXE_lab")) + .args([ + "build", + example.to_str().unwrap(), + "--target", + "workcell-star", + "--out-dir", + out_dir.to_str().unwrap(), + "--json", + ]) + .output() + .unwrap(); + assert!( + built.status.success(), + "build failed: {}", + String::from_utf8_lossy(&built.stderr) + ); + let wave = out_dir.join("workcell-star/wave-001"); + + let rendered = run(&["scene", wave.to_str().unwrap(), "--json"]); + assert!( + rendered.status.success(), + "scene failed: {}", + String::from_utf8_lossy(&rendered.stderr) + ); + let projected = run(&[ + "robot", + "task", + wave.to_str().unwrap(), + "--node", + "assembly_thermocycle.to-odtc-1", + "--json", + ]); + assert!( + projected.status.success(), + "robot task failed: {}", + String::from_utf8_lossy(&projected.stderr) + ); + let report: Value = serde_json::from_slice(&projected.stdout).unwrap(); + assert_eq!(report["status"], "robot-task"); + assert_eq!(report["result"]["object"], "reaction_plate"); + assert_eq!(report["result"]["source"], "star-1"); + assert_eq!(report["result"]["destination"], "odtc-1"); + + let task_path = wave + .join("robot-tasks") + .join("assembly_thermocycle-to-odtc-1.json"); + let task: Value = serde_json::from_str(&std::fs::read_to_string(task_path).unwrap()).unwrap(); + assert_eq!(task["format"], "lab.robot-task.v0"); + assert_eq!(task["plan"], "../plan.workcell.json"); + assert_eq!(task["scene"], "../scene.json"); + assert_eq!(task["action"], "transfer"); + assert_eq!(task["object"]["scene_node"], "reaction_plate"); + assert_eq!(task["source"]["scene_node"], "star-1"); + assert_eq!(task["destination"]["scene_node"], "odtc-1"); + assert_eq!(task["completion"]["relation"], "object-at-station"); + + let not_a_handoff = run(&[ + "robot", + "task", + wave.to_str().unwrap(), + "--node", + "assembly_run", + ]); + assert!(!not_a_handoff.status.success()); + assert!(String::from_utf8_lossy(¬_a_handoff.stderr).contains("is not a handoff")); + + std::fs::remove_dir_all(&out_dir).unwrap(); +} + #[test] fn a_facility_lays_out_the_scene_with_room_and_assets() { let example = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) diff --git a/crates/lab-compute/Cargo.toml b/crates/lab-compute/Cargo.toml new file mode 100644 index 0000000..fef6159 --- /dev/null +++ b/crates/lab-compute/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "lab-compute" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Provider-neutral batch compute contracts and provider adapters for Lab." + +[dependencies] +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true + +[dev-dependencies] +tempfile = "3.27.0" + +[lints] +workspace = true diff --git a/crates/lab-compute/README.md b/crates/lab-compute/README.md new file mode 100644 index 0000000..4be6b7f --- /dev/null +++ b/crates/lab-compute/README.md @@ -0,0 +1,20 @@ +# `lab-compute` + +`lab-compute` is the control-plane boundary for finite, artifact-producing +compute jobs. It normalizes hardware catalogs, job identity, lifecycle state, +logs, cancellation, and artifact retrieval without knowing what a robot task +means or how an Isaac environment is constructed. + +C3 is the first and primary provider. The adapter invokes C3's installed CLI +and consumes only `--json` responses for automation. Authentication remains +outside repository state: callers may pass `C3_API_KEY` to the child process, +and the `lab` CLI can read it from an ignored `.env` file without evaluating +that file as shell code. + +The provider trait accepts a provider-ready project directory at submission. +Turning a robot task, embodiment binding, and training configuration into that +directory belongs to the robot-learning integration. This keeps C3 placement +and artifact transport out of both the scientific task and the trainer. + +Tests use a temporary fake `c3` executable. No crate test can submit remote +compute or require a C3 account. diff --git a/crates/lab-compute/src/c3.rs b/crates/lab-compute/src/c3.rs new file mode 100644 index 0000000..5fc92be --- /dev/null +++ b/crates/lab-compute/src/c3.rs @@ -0,0 +1,373 @@ +//! C3 implementation of Lab's batch compute control-plane boundary. + +use std::{ + ffi::{OsStr, OsString}, + fmt, fs, + path::Path, + process::{Command, Output}, +}; + +use serde::Deserialize; + +use crate::{ + ArtifactReference, ComputeError, ComputeJob, ComputeJobState, ComputeProvider, HardwareCatalog, + HardwareProfile, JobSubmission, +}; + +/// A C3 provider driven through the stable machine-readable CLI surface. +pub struct C3Provider { + program: OsString, + api_key: Option, +} + +impl fmt::Debug for C3Provider { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("C3Provider") + .field("program", &self.program) + .field("api_key", &self.api_key.as_ref().map(|_| "[redacted]")) + .finish() + } +} + +impl Default for C3Provider { + fn default() -> Self { + Self::new("c3") + } +} + +impl C3Provider { + pub fn new(program: impl Into) -> Self { + Self { + program: program.into(), + api_key: None, + } + } + + pub fn with_api_key(mut self, api_key: impl Into) -> Self { + self.api_key = Some(api_key.into()); + self + } + + pub fn from_env_file(path: &Path) -> Result { + let api_key = dotenv_value(path, "C3_API_KEY")?.ok_or_else(|| { + ComputeError::EnvironmentFile(format!("{} has no non-empty C3_API_KEY", path.display())) + })?; + Ok(Self::default().with_api_key(api_key)) + } + + fn command(&self, arguments: I, directory: Option<&Path>) -> Result + where + I: IntoIterator, + S: AsRef, + { + let mut command = Command::new(&self.program); + command.args(arguments); + if let Some(directory) = directory { + command.current_dir(directory); + } + if let Some(api_key) = &self.api_key { + command.env("C3_API_KEY", api_key); + } + let output = command.output()?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned(); + let message = if stderr.is_empty() { + format!("exited with {}", output.status) + } else { + stderr + }; + return Err(ComputeError::Command(message)); + } + Ok(output) + } + + fn json(&self, arguments: I, directory: Option<&Path>) -> Result + where + I: IntoIterator, + S: AsRef, + T: for<'de> Deserialize<'de>, + { + let output = self.command(arguments, directory)?; + Ok(serde_json::from_slice(&output.stdout)?) + } + + pub fn artifact_reference(provider_job_id: &str) -> ArtifactReference { + ArtifactReference { + provider: "c3".to_owned(), + provider_job_id: provider_job_id.to_owned(), + remote_path: format!("/jobs/{provider_job_id}"), + } + } +} + +impl ComputeProvider for C3Provider { + fn name(&self) -> &'static str { + "c3" + } + + fn authenticate(&self) -> Result<(), ComputeError> { + let identity: serde_json::Value = self.json(["whoami", "--json"], None)?; + if !identity.is_object() { + return Err(ComputeError::MissingField("C3 identity object")); + } + Ok(()) + } + + fn hardware_catalog(&self) -> Result { + let catalog: C3Catalog = self.json(["list", "--json"], None)?; + Ok(HardwareCatalog { + provider: "c3".to_owned(), + profiles: catalog + .hardware + .into_iter() + .map(|profile| HardwareProfile { + selector: profile + .hardware_profile + .or(profile.gpu_profile) + .unwrap_or_else(|| profile.hardware_class.clone()), + display_name: profile + .display_name + .unwrap_or_else(|| profile.hardware_class.clone()), + accelerator: profile + .accelerator_kind + .unwrap_or_else(|| "unknown".to_owned()), + accelerator_count: profile.gpu_count.unwrap_or(0), + accelerator_memory_gb: profile.vram_gb, + available: profile.available.unwrap_or(false), + availability: profile.availability_tier, + price_per_hour: profile.rate_per_hour_gbp, + price_currency: profile.rate_per_hour_gbp.map(|_| "GBP".to_owned()), + }) + .collect(), + }) + } + + fn submit(&self, project_directory: &Path) -> Result { + let submitted: C3Submission = self.json(["deploy", "--json"], Some(project_directory))?; + Ok(JobSubmission { + provider: "c3".to_owned(), + provider_job_id: submitted.id, + state: normalize_state(&submitted.status), + hardware_profile: submitted.hardware_profile.or(submitted.gpu_profile), + routed_provider: submitted.provider, + dashboard_url: submitted.dashboard_url, + }) + } + + fn jobs(&self) -> Result, ComputeError> { + let jobs: Vec = self.json(["squeue", "--json"], None)?; + Ok(jobs + .into_iter() + .map(|job| ComputeJob { + provider: "c3".to_owned(), + provider_job_id: job.id, + name: job.job_name.or(job.name), + project: job.project, + state: normalize_state(&job.status), + raw_state: job.status, + hardware_profile: job.hardware_profile.or(job.gpu_profile), + routed_provider: job.provider, + }) + .collect()) + } + + fn logs(&self, provider_job_id: &str) -> Result { + let output = self.command(["logs", provider_job_id], None)?; + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) + } + + fn cancel(&self, provider_job_id: &str) -> Result<(), ComputeError> { + self.command(["cancel", provider_job_id], None)?; + Ok(()) + } + + fn pull(&self, provider_job_id: &str, destination: &Path) -> Result<(), ComputeError> { + fs::create_dir_all(destination)?; + let _: serde_json::Value = + self.json(["pull", provider_job_id, "--json"], Some(destination))?; + Ok(()) + } +} + +#[derive(Debug, Deserialize)] +struct C3Catalog { + #[serde(default)] + hardware: Vec, +} + +#[derive(Debug, Deserialize)] +struct C3Hardware { + hardware_class: String, + hardware_profile: Option, + gpu_profile: Option, + display_name: Option, + accelerator_kind: Option, + gpu_count: Option, + vram_gb: Option, + available: Option, + availability_tier: Option, + rate_per_hour_gbp: Option, +} + +#[derive(Debug, Deserialize)] +struct C3Submission { + id: String, + status: String, + provider: Option, + hardware_profile: Option, + gpu_profile: Option, + dashboard_url: Option, +} + +#[derive(Debug, Deserialize)] +struct C3Job { + #[serde(alias = "job_id")] + id: String, + status: String, + project: Option, + job_name: Option, + name: Option, + provider: Option, + hardware_profile: Option, + gpu_profile: Option, +} + +fn normalize_state(state: &str) -> ComputeJobState { + match state.to_ascii_uppercase().as_str() { + "PENDING" => ComputeJobState::Queued, + "SCHEDULING" | "PROVISIONING" | "STAGING" => ComputeJobState::Starting, + "RUNNING" | "UPLOADING" => ComputeJobState::Running, + "COMPLETED" | "SYNCED" => ComputeJobState::Succeeded, + "FAILED" => ComputeJobState::Failed, + "CANCELED" | "CANCELLED" => ComputeJobState::Canceled, + "TIMED_OUT" | "TIMEOUT" => ComputeJobState::TimedOut, + _ => ComputeJobState::Unknown, + } +} + +/// Read one value from a dotenv file without evaluating it as shell code. +pub fn dotenv_value(path: &Path, key: &str) -> Result, ComputeError> { + let text = fs::read_to_string(path)?; + for (index, raw_line) in text.lines().enumerate() { + let line = raw_line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let line = line.strip_prefix("export ").unwrap_or(line).trim_start(); + let Some((candidate, raw_value)) = line.split_once('=') else { + continue; + }; + if candidate.trim() != key { + continue; + } + let raw_value = raw_value.trim(); + let value = if raw_value.len() >= 2 + && ((raw_value.starts_with('"') && raw_value.ends_with('"')) + || (raw_value.starts_with('\'') && raw_value.ends_with('\''))) + { + &raw_value[1..raw_value.len() - 1] + } else { + raw_value + }; + if value.is_empty() { + return Err(ComputeError::EnvironmentFile(format!( + "{} line {} has an empty {key}", + path.display(), + index + 1 + ))); + } + return Ok(Some(value.to_owned())); + } + Ok(None) +} + +#[cfg(test)] +mod tests { + use std::{os::unix::fs::PermissionsExt, path::PathBuf}; + + use super::*; + + fn fake_c3(script: &str) -> (tempfile::TempDir, PathBuf) { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("c3"); + fs::write(&path, format!("#!/bin/sh\nset -eu\n{script}\n")).unwrap(); + let mut permissions = fs::metadata(&path).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&path, permissions).unwrap(); + (directory, path) + } + + #[test] + fn dotenv_reader_does_not_evaluate_shell_text() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(".env"); + fs::write( + &path, + "OTHER=value\nexport C3_API_KEY='c3_key_$(touch should-not-exist)'\n", + ) + .unwrap(); + + let value = dotenv_value(&path, "C3_API_KEY").unwrap().unwrap(); + assert_eq!(value, "c3_key_$(touch should-not-exist)"); + assert!(!directory.path().join("should-not-exist").exists()); + } + + #[test] + fn provider_debug_output_redacts_the_key() { + let provider = C3Provider::default().with_api_key("c3_key_secret"); + let text = format!("{provider:?}"); + assert!(text.contains("[redacted]")); + assert!(!text.contains("c3_key_secret")); + } + + #[test] + fn catalog_is_normalized_from_c3_json() { + let (_directory, program) = fake_c3( + r#" +if [ "$1" = "list" ]; then + printf '%s\n' '{"hardware":[{"hardware_class":"l40","hardware_profile":"l40","display_name":"NVIDIA L40","accelerator_kind":"cuda","gpu_count":1,"vram_gb":48,"available":true,"availability_tier":"high","rate_per_hour_gbp":0.948}]}' +fi +"#, + ); + let provider = C3Provider::new(program); + let catalog = provider.hardware_catalog().unwrap(); + assert_eq!(catalog.provider, "c3"); + assert_eq!(catalog.profiles.len(), 1); + assert_eq!(catalog.profiles[0].selector, "l40"); + assert_eq!(catalog.profiles[0].accelerator_memory_gb, Some(48)); + assert_eq!(catalog.profiles[0].price_currency.as_deref(), Some("GBP")); + } + + #[test] + fn submission_and_job_states_are_normalized() { + let (_directory, program) = fake_c3( + r#" +case "$1" in + deploy) + printf '%s\n' '{"id":"job_train","status":"PENDING","provider":"nextgen","hardware_profile":"l40","dashboard_url":"https://example.invalid/job_train"}' + ;; + squeue) + printf '%s\n' '[{"id":"job_train","status":"RUNNING","project":"lab-unitree","job_name":"train","provider":"nextgen","hardware_profile":"l40"}]' + ;; +esac +"#, + ); + let provider = C3Provider::new(program); + let submission = provider.submit(Path::new(".")).unwrap(); + assert_eq!(submission.provider_job_id, "job_train"); + assert_eq!(submission.state, ComputeJobState::Queued); + + let jobs = provider.jobs().unwrap(); + assert_eq!(jobs[0].state, ComputeJobState::Running); + assert_eq!(jobs[0].routed_provider.as_deref(), Some("nextgen")); + } + + #[test] + fn artifact_paths_are_provider_stable() { + assert_eq!( + C3Provider::artifact_reference("job_train").remote_path, + "/jobs/job_train" + ); + } +} diff --git a/crates/lab-compute/src/lib.rs b/crates/lab-compute/src/lib.rs new file mode 100644 index 0000000..90dcd4d --- /dev/null +++ b/crates/lab-compute/src/lib.rs @@ -0,0 +1,140 @@ +//! Provider-neutral contracts for finite, artifact-producing compute jobs. +//! +//! A compute provider owns placement, provisioning, and transport. Lab owns +//! the identity and state of the work it requested and the provenance of the +//! artifacts it receives. Robot-task semantics do not cross this boundary. + +pub mod c3; + +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +/// The normalized lifecycle shared by every batch compute provider. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ComputeJobState { + Queued, + Starting, + Running, + Succeeded, + Failed, + Canceled, + TimedOut, + Unknown, +} + +impl ComputeJobState { + /// Whether the provider has reached a terminal state for this job. + pub fn is_terminal(self) -> bool { + matches!( + self, + Self::Succeeded | Self::Failed | Self::Canceled | Self::TimedOut + ) + } +} + +/// One provider-visible hardware choice. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct HardwareProfile { + pub selector: String, + pub display_name: String, + pub accelerator: String, + pub accelerator_count: u32, + pub accelerator_memory_gb: Option, + pub available: bool, + pub availability: Option, + pub price_per_hour: Option, + pub price_currency: Option, +} + +/// A provider's current public hardware catalog. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct HardwareCatalog { + pub provider: String, + pub profiles: Vec, +} + +/// The durable identity returned after a provider accepts a job. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct JobSubmission { + pub provider: String, + pub provider_job_id: String, + pub state: ComputeJobState, + pub hardware_profile: Option, + pub routed_provider: Option, + pub dashboard_url: Option, +} + +/// A normalized provider job-list entry. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ComputeJob { + pub provider: String, + pub provider_job_id: String, + pub name: Option, + pub project: Option, + pub state: ComputeJobState, + pub raw_state: String, + pub hardware_profile: Option, + pub routed_provider: Option, +} + +/// A provider-neutral reference to one remote artifact tree. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ArtifactReference { + pub provider: String, + pub provider_job_id: String, + pub remote_path: String, +} + +/// The small control-plane surface Lab needs from a batch provider. +/// +/// `submit` accepts a provider-ready project directory. Compiling a semantic +/// robot task into such a project belongs to the robot-training integration, +/// not to this control-plane trait. +pub trait ComputeProvider { + fn name(&self) -> &'static str; + + fn authenticate(&self) -> Result<(), ComputeError>; + + fn hardware_catalog(&self) -> Result; + + fn submit(&self, project_directory: &Path) -> Result; + + fn jobs(&self) -> Result, ComputeError>; + + fn logs(&self, provider_job_id: &str) -> Result; + + fn cancel(&self, provider_job_id: &str) -> Result<(), ComputeError>; + + fn pull(&self, provider_job_id: &str, destination: &Path) -> Result<(), ComputeError>; +} + +#[derive(Debug, thiserror::Error)] +pub enum ComputeError { + #[error("failed to run compute provider command: {0}")] + Io(#[from] std::io::Error), + #[error("compute provider command failed: {0}")] + Command(String), + #[error("compute provider returned invalid JSON: {0}")] + Json(#[from] serde_json::Error), + #[error("compute provider response is missing {0}")] + MissingField(&'static str), + #[error("invalid environment file: {0}")] + EnvironmentFile(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_terminal_states_stop_polling() { + assert!(!ComputeJobState::Queued.is_terminal()); + assert!(!ComputeJobState::Running.is_terminal()); + assert!(ComputeJobState::Succeeded.is_terminal()); + assert!(ComputeJobState::Failed.is_terminal()); + assert!(ComputeJobState::Canceled.is_terminal()); + assert!(ComputeJobState::TimedOut.is_terminal()); + } +} diff --git a/crates/lab-runfmt/src/lib.rs b/crates/lab-runfmt/src/lib.rs index 7db0020..8f4f342 100644 --- a/crates/lab-runfmt/src/lib.rs +++ b/crates/lab-runfmt/src/lib.rs @@ -11,8 +11,13 @@ //! way everywhere. pub mod facility; +mod robot_task; mod trace; +pub use robot_task::{ + ROBOT_TASK_FORMAT, RobotTaskAction, RobotTaskCompletion, RobotTaskDocument, RobotTaskEndpoint, + RobotTaskObject, load_robot_task, +}; pub use trace::{ AttentionWindow, ProgramExtent, RunEvent, SIM_TRACE_FORMAT, SimSummary, SimTraceDocument, StationSummary, TimedEvent, summarize, diff --git a/crates/lab-runfmt/src/robot_task.rs b/crates/lab-runfmt/src/robot_task.rs new file mode 100644 index 0000000..85208ae --- /dev/null +++ b/crates/lab-runfmt/src/robot_task.rs @@ -0,0 +1,131 @@ +//! Backend-neutral robot tasks projected from reviewed workcell plans. +//! +//! These documents preserve the semantic intent of one physical handoff. +//! Robot models, controllers, collision geometry, calibrated poses, and +//! randomization belong to a simulator binding, not to this format. + +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use crate::{RunDocumentError, check_format, load_document}; + +/// The format string every `lab.robot-task.v0` document declares. +pub const ROBOT_TASK_FORMAT: &str = "lab.robot-task.v0"; + +/// One robot-learning task projected from a workcell-plan node. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RobotTaskDocument { + /// Always [`ROBOT_TASK_FORMAT`]; readers reject any other value. + pub format: String, + /// The source node's stable identity. + pub id: String, + /// The reviewed plan this task was projected from. + pub plan: String, + /// The semantic scene whose stable node identities the task uses. + pub scene: String, + /// Plan-node identities that must complete before this task begins. + #[serde(default)] + pub after: Vec, + #[serde(flatten)] + pub action: RobotTaskAction, +} + +/// The physical intent a robot policy must accomplish. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "action", rename_all = "kebab-case")] +pub enum RobotTaskAction { + /// Transfer one named object between two semantic station endpoints. + Transfer { + object: RobotTaskObject, + source: RobotTaskEndpoint, + destination: RobotTaskEndpoint, + instructions: String, + completion: RobotTaskCompletion, + }, +} + +/// A labware object and its stable node in the semantic scene. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RobotTaskObject { + pub labware: String, + pub scene_node: String, +} + +/// A workcell station and its stable node in the semantic scene. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RobotTaskEndpoint { + pub station: String, + pub scene_node: String, +} + +/// A semantic success condition. Simulator bindings turn this relation into +/// measurable position, orientation, contact, and settling tolerances. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RobotTaskCompletion { + pub relation: String, + pub object: String, + pub target: String, +} + +/// Load and format-check one `lab.robot-task.v0` document. +pub fn load_robot_task(path: &Path) -> Result { + let document: RobotTaskDocument = load_document(path)?; + check_format(path, ROBOT_TASK_FORMAT, &document.format)?; + Ok(document) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn transfer_task() -> RobotTaskDocument { + RobotTaskDocument { + format: ROBOT_TASK_FORMAT.to_string(), + id: "assembly_thermocycle.to-odtc-1".to_string(), + plan: "plan.workcell.json".to_string(), + scene: "scene.json".to_string(), + after: vec!["assembly_run".to_string()], + action: RobotTaskAction::Transfer { + object: RobotTaskObject { + labware: "reaction_plate".to_string(), + scene_node: "reaction_plate".to_string(), + }, + source: RobotTaskEndpoint { + station: "star-1".to_string(), + scene_node: "star-1".to_string(), + }, + destination: RobotTaskEndpoint { + station: "odtc-1".to_string(), + scene_node: "odtc-1".to_string(), + }, + instructions: "Seal and transfer the plate.".to_string(), + completion: RobotTaskCompletion { + relation: "object-at-station".to_string(), + object: "reaction_plate".to_string(), + target: "odtc-1".to_string(), + }, + }, + } + } + + #[test] + fn a_robot_task_round_trips_through_json() { + let document = transfer_task(); + let text = serde_json::to_string_pretty(&document).expect("the task serializes"); + let back: RobotTaskDocument = serde_json::from_str(&text).expect("the task parses"); + assert_eq!(back, document); + } + + #[test] + fn the_loader_rejects_an_unknown_format() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("task.json"); + let mut value = serde_json::to_value(transfer_task()).unwrap(); + value["format"] = serde_json::json!("lab.robot-task.v1"); + std::fs::write(&path, serde_json::to_string(&value).unwrap()).unwrap(); + + let error = load_robot_task(&path).unwrap_err().to_string(); + assert!(error.contains("expects 'lab.robot-task.v0'"), "{error}"); + } +} diff --git a/docs/README.md b/docs/README.md index bb9e1ab..cdc8d61 100644 --- a/docs/README.md +++ b/docs/README.md @@ -64,6 +64,8 @@ Decision records preserve the reasoning and status behind the language rather th | [0034: The simulator is an interpreter](language/decisions/0034-the-simulator-is-an-interpreter.md) | `lab simulate` interprets the same run documents `lab run` executes; the trace is the visualization contract | | [0035: Facility files](language/decisions/0035-facility-files.md) | a facility is its own file under `facilities/`; the manifest carries at most a pointer | | [0036: Photoreal projections](language/decisions/0036-photoreal-projections.md) | renderers are players of the scene and trace; assets are facility-owned references with box fallbacks | +| [0037: Robot learning as a physics projection](language/decisions/0037-robot-learning-is-a-physics-projection.md) | reviewed handoffs project to semantic robot tasks; embodiment and physics remain explicit simulator bindings | +| [0038: C3 as the primary compute provider](language/decisions/0038-c3-is-the-primary-compute-provider.md) | C3 runs finite training jobs behind provider-neutral lifecycle and artifact contracts; Isaac uses L40 capacity | ## Implementation and embedding @@ -72,9 +74,11 @@ Decision records preserve the reasoning and status behind the language rather th - [Compiler internals](../crates/lab-compiler/README.md) describes the current compiler pipeline and developer commands. - [Language frontend](../crates/lab-language/README.md) describes the source-preserving and checked frontend boundaries. - [Project CLI](../crates/lab-cli/README.md) documents the current `lab` project loop. +- [Compute control plane](../crates/lab-compute/README.md) documents the C3-first batch job boundary. - [VS Code and Cursor](../editors/vscode/README.md) documents editor extension development. - The [`lab-compiler`](../crates/lab-compiler/README.md) crate is the Rust embedding API; the [Python SDK](../crates/lab-python/README.md) exposes the same checked frontend through PyO3. - [Lab-native Opentrons build specialization](integrations/opentrons-build.md) records the source, dependency, and hardware-lowering boundary for manual and OT-2 output. +- [Isaac Lab plate-transfer prototype](../integrations/isaac-lab/README.md) projects a checked workcell handoff into a manager-based RL environment without conflating workflow simulation and physics episodes. ## Examples versus specimens diff --git a/docs/language/README.md b/docs/language/README.md index 2e8958d..3d026e5 100644 --- a/docs/language/README.md +++ b/docs/language/README.md @@ -77,5 +77,11 @@ The latest accepted design records are: - [`0028`](decisions/0028-schemas-are-contributed-to.md): several packages describe one kind; - [`0029`](decisions/0029-backend-dispatch.md): a profile's backend key selects its backend; - [`0030`](decisions/0030-reviewed-frames-are-the-execution-boundary.md): reviewed frames are the execution boundary; -- [`0031`](decisions/0031-workcell-targets.md): a workcell target composes stations, and assignment is planning rather than language; and -- [`0032`](decisions/0032-provenance-blocks.md): a provenance verb can open a block. +- [`0031`](decisions/0031-workcell-targets.md): a workcell target composes stations, and assignment is planning rather than language; +- [`0032`](decisions/0032-provenance-blocks.md): a provenance verb can open a block; +- [`0033`](decisions/0033-typeset-protocol-documents.md): protocol documents are typeset PDFs; +- [`0034`](decisions/0034-the-simulator-is-an-interpreter.md): simulation interprets reviewed run documents; +- [`0035`](decisions/0035-facility-files.md): facilities live outside package manifests; +- [`0036`](decisions/0036-photoreal-projections.md): renderers play the shared scene and trace; +- [`0037`](decisions/0037-robot-learning-is-a-physics-projection.md): reviewed handoffs project into robot-learning tasks while physics stays in simulator bindings; and +- [`0038`](decisions/0038-c3-is-the-primary-compute-provider.md): C3 is the primary finite-job compute provider behind a provider-neutral lifecycle and artifact boundary. diff --git a/docs/language/decisions/0037-robot-learning-is-a-physics-projection.md b/docs/language/decisions/0037-robot-learning-is-a-physics-projection.md new file mode 100644 index 0000000..0fa105a --- /dev/null +++ b/docs/language/decisions/0037-robot-learning-is-a-physics-projection.md @@ -0,0 +1,65 @@ +# 0037 — Robot learning is a physics projection of reviewed handoffs + +## Status + +Accepted. + +## Context + +The workcell planner already states every physical movement as an explicit, +reviewed handoff between named stations. The semantic scene gives those +stations and their labware stable identities, while `lab simulate` interprets +the same run documents to estimate workflow time and operator attention. + +An RL environment needs a different model: rigid bodies, collision meshes, +actuators, observations, actions, rewards, termination conditions, randomized +parameters, and thousands of independent episode clocks. Adding those fields +to a workcell plan would make laboratory intent depend on one robot and one +physics engine. Reusing `lab.sim-trace.v0` for policy rollouts would also blur +an operational schedule with high-rate training telemetry. + +## Decision + +A robot-learning task is a projection of exactly one reviewed handoff. The +versioned `lab.robot-task.v0` document records the source plan node, its +dependencies, the named labware object, source and destination stations, +semantic scene-node identities, operator instructions, and the semantic +completion relation. `lab robot task` emits the projection only after all +three identities resolve uniquely with the right kinds in `lab.scene.v0`. + +Everything that depends on the physical embodiment lives in a simulator +binding beside, not inside, that task: robot and controller, collision shape, +mass and friction, calibrated poses, reset variation, measurement tolerances, +physics rate, and episode count. A binding must state its calibration status +and provenance. A hand-authored proxy is useful for software integration but +is not real-to-sim calibration. + +Isaac Lab is the first projection. Its prototype adapts the manager-based +Franka relative-IK lift environment to a gripper-compatible plate proxy, a +fixed source and destination, reset-position variation, and a terminal +condition that requires pose tolerance, low linear and angular velocity, and a +released gripper. The proxy is intentionally smaller than an SBS plate because +the stock Franka demo does not establish a qualified full-plate grasp. Isaac +imports stay behind the adapter so contract checks run without a GPU; the +actual PhysX smoke gate runs only in a supported Isaac Lab Linux/CUDA +environment. + +Policy trajectories and training metrics will gain their own episode format +when persistence is needed. They are not `lab.sim-trace.v0`. Deployment is +also a separate reviewed step: a learned transport policy may later implement +a workcell transport station, but it does not silently replace the handoff in +an approved plan. + +## Consequences + +The scientific workflow remains portable while robot embodiments and physics +models can evolve independently. One handoff can be tested with several arms, +controllers, scene captures, and calibrated bindings without recompiling the +experiment. Plan identity survives through training, making later evaluation +and deployment provenance possible. + +The first prototype is deliberately not a digital twin. Replacing its proxy +geometry requires measured station frames, plate and gripper assets with +collision meshes, dynamics calibration, and a real-to-sim capture pipeline. +Those improvements refine the binding and facility assets rather than changing +what the workcell plan means. diff --git a/docs/language/decisions/0038-c3-is-the-primary-compute-provider.md b/docs/language/decisions/0038-c3-is-the-primary-compute-provider.md new file mode 100644 index 0000000..e0fe136 --- /dev/null +++ b/docs/language/decisions/0038-c3-is-the-primary-compute-provider.md @@ -0,0 +1,69 @@ +# 0038 — C3 is the primary compute provider + +## Status + +Accepted. + +## Context + +Robot learning needs Linux CUDA machines that can run thousands of parallel +physics environments, retain checkpoints, and support reproducible evaluation. +Those resources are operational infrastructure, not properties of a reviewed +workcell handoff or its `lab.robot-task.v0` projection. The local development +machine also cannot validate the actual Isaac runtime. + +C3 provides finite jobs, hardware selection, marketplace routing, locked Python +or Docker environments, content-addressed inputs, machine-readable lifecycle +operations, and collected artifacts. Its job model matches training and +evaluation without requiring Lab to own virtual machines or provider accounts. + +Isaac Sim narrows the usable hardware. It requires an RTX-capable GPU, while +A100 and H100 do not have the required RT cores. C3's L40 class is the initial +supported Isaac choice. C3 Docker projects currently accept public Docker Hub +images, while NVIDIA distributes the supported Isaac Lab container from NGC, +so the first runtime uses Isaac Lab's published Python packages and a checked +`uv.lock`. + +## Decision + +C3 is the first and primary remote compute provider for Lab. A small +`lab-compute` crate owns provider-neutral job states, hardware descriptions, +submission identities, artifact references, and the lifecycle operations Lab +needs: authenticate, list hardware, submit, list jobs, read logs, cancel, and +pull artifacts. + +The C3 implementation invokes the installed `c3` CLI and parses only its JSON +automation output. It does not reproduce C3's HTTP API or routing logic. +Credentials remain external to tracked project state. `lab compute doctor` +may read `C3_API_KEY` from an ignored `.env`, verifies authentication and the +current L40 catalog, and never submits a job. + +A robot trainer compiles its task, embodiment binding, training configuration, +and runner into a provider-ready project before calling the compute boundary. +The provider does not interpret those files. A returned training manifest +records both the Lab inputs and C3 job, routed provider, and concrete hardware +profile. Checkpoints and metrics are compute artifacts, never +`lab.sim-trace.v0` events. + +The first paid gate is a bounded L40 capability probe using stable Isaac Lab +2.3.2, Isaac Sim 5.1, Python 3.11, CUDA PyTorch 2.7, and the repository's +existing PhysX smoke environment. No training command becomes supported until +that gate runs successfully on C3. + +## Consequences + +The common interface follows a real provider's batch and artifact semantics +instead of speculating about generic clouds. Another provider can be added +later without changing robot tasks or training manifests, but it must satisfy +the same observable lifecycle. + +C3 availability, pricing, credentials, and billing stay C3 responsibilities. +Lab preserves the provider's raw status alongside its normalized state and +does not silently resubmit failed work. Resume and evaluation may mount prior +C3 job artifacts server-to-server rather than downloading large checkpoints +through the local machine. + +The locked Python runtime avoids the Docker Hub versus NGC registry mismatch, +but it does not prove C3 host compatibility. Driver, GLIBC, system memory, +disk, first-run extension access, and headless PhysX remain explicitly +unverified until the capability artifact exists. diff --git a/examples/golden-gate/README.md b/examples/golden-gate/README.md index 2fae5b4..93b6031 100644 --- a/examples/golden-gate/README.md +++ b/examples/golden-gate/README.md @@ -52,6 +52,19 @@ lab render # Blender frames and a movie from those outputs, per wave Each command skips work whose inputs have not changed, so rerunning any of them costs nothing until the build, the facility, or a setting moves. +The first machine-to-machine learning prototype projects wave 1's reviewed +STAR-to-ODTC handoff into a backend-neutral robot task: + +```bash +lab robot task .lab/build/workcell-star/wave-001 \ + --node assembly_thermocycle.to-odtc-1 +``` + +The command validates the source and destination stations and +`reaction_plate` against the wave's semantic scene before writing the task. +The [Isaac Lab adapter](../../integrations/isaac-lab/README.md) binds that +portable intent to the initial Franka plate-transfer proxy. + `facility.toml` at the package root describes the room the simulation runs in: station positions, storage, and transport times. `lab simulate` and `lab render` pick it up by convention; `--facility` names another one. diff --git a/integrations/isaac-lab/.c3 b/integrations/isaac-lab/.c3 new file mode 100644 index 0000000..af7ba80 --- /dev/null +++ b/integrations/isaac-lab/.c3 @@ -0,0 +1,12 @@ +project: lab-isaac-capability +job_name: isaac-lab-capability +script: c3/probe.sh +hardware: l40 +time: "00:20:00" + +capacity: + on_unavailable: fail + max_wait_minutes: 10 + +python: + project: ./c3/runtime diff --git a/integrations/isaac-lab/README.md b/integrations/isaac-lab/README.md new file mode 100644 index 0000000..292e99c --- /dev/null +++ b/integrations/isaac-lab/README.md @@ -0,0 +1,124 @@ +# Lab to Isaac Lab plate-transfer prototype + +This project turns a real Lab workcell handoff into a checked Isaac Lab +manager-based RL environment configuration. It keeps two contracts separate: + +```mermaid +flowchart LR + P["Reviewed plan.workcell.json"] --> T["lab.robot-task.v0"] + S["Semantic lab.scene.v0"] --> T + T --> A["Isaac adapter"] + B["Calibrated or proxy binding"] --> A + A --> E["Parallel PhysX episodes"] + P --> W["lab simulate"] + W --> R["Workflow sim-trace.json"] +``` + +`sim-trace.json` remains the schedule and operator-attention record. RL +observations, actions, rewards, and episode telemetry belong to the Isaac side. + +## Generate the Golden Gate task + +From the repository root: + +```sh +lab build examples/golden-gate +lab scene examples/golden-gate +lab robot task \ + examples/golden-gate/.lab/build/workcell-star/wave-001 \ + --node assembly_thermocycle.to-odtc-1 +``` + +The last command writes: + +```text +examples/golden-gate/.lab/build/workcell-star/wave-001/robot-tasks/ + assembly_thermocycle-to-odtc-1.json +``` + +It fails if the node is not a handoff or if `reaction_plate`, `star-1`, or +`odtc-1` does not resolve uniquely with the right semantic kind in +`scene.json`. Plan and scene references are relative to the task file, so the +wave remains portable as a directory. + +## Validate the adapter contract anywhere + +The parser and cross-checks have no Isaac dependency, so they run on macOS and +in ordinary CI: + +```sh +uv run --project integrations/isaac-lab --locked lab-isaac inspect \ + --task examples/golden-gate/.lab/build/workcell-star/wave-001/robot-tasks/assembly_thermocycle-to-odtc-1.json \ + --binding integrations/isaac-lab/examples/golden-gate-plate-transfer.binding.toml \ + --json +``` + +The included binding is explicitly `prototype-proxy`. The stock Franka demo +does not establish a qualified grasp for a full SBS plate, so this first body +is a scaled, gripper-compatible plate proxy. It maps the real station +identities onto two reachable poses on the stock Franka table. Its values were +not measured from a physical plate, Hamilton STAR, ODTC, or gripper. + +## Run the PhysX smoke gate + +Install this project into the Python environment supplied by a current Isaac +Lab installation on supported Linux/CUDA hardware, then run: + +```sh +lab-isaac smoke \ + --task /path/to/assembly_thermocycle-to-odtc-1.json \ + --binding /path/to/golden-gate-plate-transfer.binding.toml \ + --num-envs 32 \ + --steps 8 +``` + +The gate launches the stock Franka relative-IK lift environment, replaces the +cube with the bound plate cuboid and dynamics, installs the source reset range +and destination command, resets every parallel environment, and submits +policy-shaped actions. Success requires the object to reach the commanded pose +within position and orientation tolerances, settle below configured linear and +angular velocity limits, and be released by the gripper. The same predicate +provides a sparse success reward and terminates the episode. + +The adapter was source-checked against Isaac Lab's current upstream +[manager-based lift configuration](https://github.com/isaac-sim/IsaacLab/blob/main/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/lift_env_cfg.py) +and [Franka relative-IK specialization](https://github.com/isaac-sim/IsaacLab/blob/main/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/franka/ik_rel_env_cfg.py). +That is not a substitute for running the smoke gate in the actual Isaac +runtime. + +This smoke command is an environment-construction gate, not training. The next +slice is to expose the configuration through an Isaac task registry and train +a baseline policy before adding scan-derived geometry, grasp randomization, +vision observations, or a humanoid embodiment. A real plate-transfer milestone +also requires a plate-compatible end effector (or carrier) and measured plate +collision geometry; scaling the proxy to SBS dimensions alone would make the +stock Franka grasp invalid. + +## C3 compute gate + +C3 is the primary supported remote compute provider. The tracked [`.c3`](.c3) +project requests one L40-class GPU and uses the locked Python runtime under +[`c3/runtime`](c3/runtime) to run this same smoke gate. It is a capability +probe, not a trainer, and its twenty-minute maximum does not authorize a paid +submission. + +Before reviewing a capability submission, validate the ignored local +credential and live catalog without creating a job: + +```sh +lab compute doctor +``` + +The probe writes `lab.compute-capability.v0` to C3's artifact directory even +when Isaac startup fails. It records host, driver, CUDA, package, and smoke +facts but never copies credentials or the complete process environment. + +## Development checks + +```sh +cd integrations/isaac-lab +uv run --locked ruff format --check src tests c3/probe.py +uv run --locked ruff check src tests c3/probe.py +uv run --locked mypy +uv run --locked pytest +``` diff --git a/integrations/isaac-lab/c3/README.md b/integrations/isaac-lab/c3/README.md new file mode 100644 index 0000000..b993be3 --- /dev/null +++ b/integrations/isaac-lab/c3/README.md @@ -0,0 +1,25 @@ +# C3 Isaac Lab capability gate + +This project is the first remote-compute gate for Lab robot learning. It asks +C3 for one L40-class GPU, installs a locked Isaac Lab runtime, launches the +existing plate-transfer environment headlessly, and returns one +`capability.json` artifact. It does not train a policy. + +The tracked [`.c3`](../.c3) file deliberately requests `hardware: l40`. +Isaac Sim requires an RTX-capable GPU; C3's A100 and H100 classes are therefore +not valid substitutes even though they provide more CUDA memory. + +Before any paid submission, validate the local credential and current catalog: + +```sh +lab compute doctor +``` + +The actual capability run is an external, billable action and must be reviewed +before invoking `c3 deploy` from `integrations/isaac-lab`. Its runtime is +bounded at twenty minutes and fails rather than waiting on unavailable +capacity. C3 bills actual compute time, not the declared maximum. + +The probe records only runtime and hardware facts needed to qualify the +environment. It never records environment variables wholesale, the C3 API +key, user identity, or other credentials. diff --git a/integrations/isaac-lab/c3/probe.py b/integrations/isaac-lab/c3/probe.py new file mode 100644 index 0000000..8822419 --- /dev/null +++ b/integrations/isaac-lab/c3/probe.py @@ -0,0 +1,110 @@ +"""Bounded C3 host and Isaac Lab capability probe.""" + +from __future__ import annotations + +import argparse +import json +import os +import platform +import shutil +import subprocess +import sys +from pathlib import Path + +JsonObject = dict[str, object] + + +def _arguments() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--task", type=Path, required=True) + parser.add_argument("--binding", type=Path, required=True) + parser.add_argument("--num-envs", type=int, default=32) + parser.add_argument("--steps", type=int, default=8) + return parser.parse_args() + + +def _command(arguments: list[str]) -> str: + result = subprocess.run(arguments, check=True, capture_output=True, text=True) + return result.stdout.strip() + + +def _host_report() -> JsonObject: + disk = shutil.disk_usage(Path.cwd()) + return { + "platform": platform.platform(), + "machine": platform.machine(), + "python": sys.version, + "libc": platform.libc_ver(), + "cpu_count": os.cpu_count(), + "workspace_disk_bytes": { + "total": disk.total, + "free": disk.free, + }, + "c3": { + "hardware_profile": os.environ.get("C3_HARDWARE_PROFILE"), + "hardware_kind": os.environ.get("C3_HARDWARE_KIND"), + "accelerator_kind": os.environ.get("C3_ACCELERATOR_KIND"), + }, + "nvidia_smi": _command( + [ + "nvidia-smi", + "--query-gpu=name,driver_version,memory.total", + "--format=csv,noheader,nounits", + ] + ), + } + + +def _write_report(report: JsonObject) -> Path: + destination = Path(os.environ.get("C3_ARTIFACTS_DIR", "artifacts")) + destination.mkdir(parents=True, exist_ok=True) + path = destination / "capability.json" + path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n") + return path + + +def main() -> None: + arguments = _arguments() + report: JsonObject = { + "format": "lab.compute-capability.v0", + "provider": "c3", + "status": "failed", + } + try: + report["host"] = _host_report() + + import torch + + report["torch"] = { + "version": torch.__version__, + "cuda_available": torch.cuda.is_available(), + "cuda_version": torch.version.cuda, + "device_count": torch.cuda.device_count(), + "device_name": torch.cuda.get_device_name(0) if torch.cuda.is_available() else None, + } + if not torch.cuda.is_available(): + raise RuntimeError("PyTorch cannot see a CUDA device") + + from lab_isaac.contract import load_prototype + from lab_isaac.isaac_env import run_smoke + + prototype = load_prototype(arguments.task, arguments.binding) + report["smoke"] = run_smoke( + prototype, + num_envs=arguments.num_envs, + steps=arguments.steps, + ) + report["status"] = "passed" + except Exception as error: + report["error"] = { + "type": type(error).__name__, + "message": str(error), + } + _write_report(report) + raise + path = _write_report(report) + print(json.dumps({"status": report["status"], "artifact": str(path)})) + + +if __name__ == "__main__": + main() diff --git a/integrations/isaac-lab/c3/probe.sh b/integrations/isaac-lab/c3/probe.sh new file mode 100755 index 0000000..3835c6b --- /dev/null +++ b/integrations/isaac-lab/c3/probe.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +export ACCEPT_EULA=Y +export PRIVACY_CONSENT=Y + +python c3/probe.py \ + --task tests/fixtures/robot-tasks/task.json \ + --binding examples/golden-gate-plate-transfer.binding.toml \ + --num-envs 32 \ + --steps 8 diff --git a/integrations/isaac-lab/c3/runtime/pyproject.toml b/integrations/isaac-lab/c3/runtime/pyproject.toml new file mode 100644 index 0000000..3a06444 --- /dev/null +++ b/integrations/isaac-lab/c3/runtime/pyproject.toml @@ -0,0 +1,37 @@ +[build-system] +requires = ["hatchling>=1.27,<2"] +build-backend = "hatchling.build" + +[project] +name = "lab-isaac-c3-runtime" +version = "0.1.0" +description = "Locked C3 runtime for the Lab Isaac capability gate" +requires-python = ">=3.11,<3.12" +dependencies = [ + "isaaclab[isaacsim,all]==2.3.2.post1", + "lab-isaac", + "rsl-rl-lib==3.0.1", + "torch==2.7.0; sys_platform == 'linux' and platform_machine == 'x86_64'", + "torchvision==0.22.0; sys_platform == 'linux' and platform_machine == 'x86_64'", +] + +[tool.uv.sources] +lab-isaac = { path = "../..", editable = false } +torch = [ + { index = "pytorch-cu128", marker = "sys_platform == 'linux' and platform_machine == 'x86_64'" }, +] +torchvision = [ + { index = "pytorch-cu128", marker = "sys_platform == 'linux' and platform_machine == 'x86_64'" }, +] + +[[tool.uv.index]] +name = "nvidia" +url = "https://pypi.nvidia.com" + +[[tool.uv.index]] +name = "pytorch-cu128" +url = "https://download.pytorch.org/whl/cu128" +explicit = true + +[tool.hatch.build.targets.wheel] +packages = [] diff --git a/integrations/isaac-lab/c3/runtime/uv.lock b/integrations/isaac-lab/c3/runtime/uv.lock new file mode 100644 index 0000000..8efc1f7 --- /dev/null +++ b/integrations/isaac-lab/c3/runtime/uv.lock @@ -0,0 +1,3087 @@ +version = 1 +revision = 1 +requires-python = "==3.11.*" +resolution-markers = [ + "platform_machine != 's390x' and sys_platform == 'darwin'", + "platform_machine == 's390x' and sys_platform == 'darwin'", + "(platform_machine != 'aarch64' and platform_machine != 's390x' and sys_platform == 'linux') or (platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')", + "platform_machine == 's390x' and sys_platform != 'darwin' and sys_platform != 'win32'", + "platform_machine != 's390x' and sys_platform == 'win32'", + "platform_machine == 's390x' and sys_platform == 'win32'", + "platform_machine == 'aarch64' and sys_platform == 'linux'", +] + +[[package]] +name = "absl-py" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/4f/d79676ab82f2e42fc3611618139f13a9c4c31d0cff4b486982047679a802/absl_py-2.5.0.tar.gz", hash = "sha256:0c996f25c0490700fadabe6351630f6111534fa0ae252cc6d2014ea3b141135f", size = 118119 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/0a/a10b45aab35b175aded078a462dc8d0c698f5b13946e7cb0869097b78bb6/absl_py-2.5.0-py3-none-any.whl", hash = "sha256:0f17b89f2a4eaaedc4f28c622998aa690564b3012a396a4ffad0821007fe03ba", size = 137410 }, +] + +[[package]] +name = "aioboto3" +version = "15.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiobotocore", extra = ["boto3"] }, + { name = "aiofiles" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/b1/b0331786c50f6ef881f9a71c3441ccf7b64c7eed210297d882c37ce31713/aioboto3-15.1.0.tar.gz", hash = "sha256:37763bbc6321ceb479106dc63bc84c8fdb59dd02540034a12941aebef2057c5c", size = 234664 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/b0/28e3ac89e7119b1cb4e6830664060b96a2b5761291e92a10fb3044b5a11d/aioboto3-15.1.0-py3-none-any.whl", hash = "sha256:66006142a2ccc7d6d07aa260ba291c4922b6767d270ba42f95c59e85d8b3e645", size = 35791 }, +] + +[[package]] +name = "aiobotocore" +version = "2.24.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "aioitertools" }, + { name = "botocore" }, + { name = "jmespath" }, + { name = "multidict" }, + { name = "python-dateutil" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/ca/ac82c0c699815b6d5b4017f3d8fb2c2d49537f4937f4a0bdf58b4c75d321/aiobotocore-2.24.0.tar.gz", hash = "sha256:b32c0c45d38c22a18ce395a0b5448606c5260603296a152895b5bdb40ab3139d", size = 119597 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/68/b29577197aa2e54b50d6f214524790cc1cb27d289585ad7c7bdfe5125285/aiobotocore-2.24.0-py3-none-any.whl", hash = "sha256:72bb1f8eb1b962779a95e1bcc9cf35bc33196ad763b622a40ae7fa9d2e95c87c", size = 84971 }, +] + +[package.optional-dependencies] +boto3 = [ + { name = "boto3" }, +] + +[[package]] +name = "aiodns" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycares" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/10/4de99e6e67703d8f6b10ea92a4d2a6c5b96a9c0708b75389a00203387925/aiodns-3.1.1.tar.gz", hash = "sha256:1073eac48185f7a4150cad7f96a5192d6911f12b4fb894de80a088508c9b3a99", size = 7363 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/74/976abff30200cb0cab0bd076db074b8cdda9236ba885ebe3f4d91c7e074b/aiodns-3.1.1-py3-none-any.whl", hash = "sha256:a387b63da4ced6aad35b1dda2d09620ad608a1c7c0fb71efa07ebb4cd511928d", size = 5392 }, +] + +[[package]] +name = "aiofiles" +version = "23.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/41/cfed10bc64d774f497a86e5ede9248e1d062db675504b41c320954d99641/aiofiles-23.2.1.tar.gz", hash = "sha256:84ec2218d8419404abcb9f0c02df3f34c6e0a68ed41072acfb1cef5cbc29051a", size = 32072 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/19/5af6804c4cc0fed83f47bff6e413a98a36618e7d40185cd36e69737f3b0e/aiofiles-23.2.1-py3-none-any.whl", hash = "sha256:19297512c647d4b27a2cf7c34caa7e405c0d60b5560618a29a9fe027b18b0107", size = 15727 }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/55/e4373e888fdacb15563ef6fa9fa8c8252476ea071e96fb46defac9f18bf2/aiohappyeyeballs-2.4.4.tar.gz", hash = "sha256:5fdd7d87889c63183afc18ce9271f9b0a7d32c2303e394468dd45d514a757745", size = 21977 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/74/fbb6559de3607b3300b9be3cc64e97548d55678e44623db17820dbd20002/aiohappyeyeballs-2.4.4-py3-none-any.whl", hash = "sha256:a980909d50efcd44795c4afeca523296716d50cd756ddca6af8c65b996e27de8", size = 14756 }, +] + +[[package]] +name = "aiohttp" +version = "3.11.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/ed/f26db39d29cd3cb2f5a3374304c713fe5ab5a0e4c8ee25a0c45cc6adf844/aiohttp-3.11.11.tar.gz", hash = "sha256:bb49c7f1e6ebf3821a42d81d494f538107610c3a705987f53068546b0e90303e", size = 7669618 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/ae/e8806a9f054e15f1d18b04db75c23ec38ec954a10c0a68d3bd275d7e8be3/aiohttp-3.11.11-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ba74ec819177af1ef7f59063c6d35a214a8fde6f987f7661f4f0eecc468a8f76", size = 708624 }, + { url = "https://files.pythonhosted.org/packages/c7/e0/313ef1a333fb4d58d0c55a6acb3cd772f5d7756604b455181049e222c020/aiohttp-3.11.11-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4af57160800b7a815f3fe0eba9b46bf28aafc195555f1824555fa2cfab6c1538", size = 468507 }, + { url = "https://files.pythonhosted.org/packages/a9/60/03455476bf1f467e5b4a32a465c450548b2ce724eec39d69f737191f936a/aiohttp-3.11.11-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ffa336210cf9cd8ed117011085817d00abe4c08f99968deef0013ea283547204", size = 455571 }, + { url = "https://files.pythonhosted.org/packages/be/f9/469588603bd75bf02c8ffb8c8a0d4b217eed446b49d4a767684685aa33fd/aiohttp-3.11.11-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81b8fe282183e4a3c7a1b72f5ade1094ed1c6345a8f153506d114af5bf8accd9", size = 1685694 }, + { url = "https://files.pythonhosted.org/packages/88/b9/1b7fa43faf6c8616fa94c568dc1309ffee2b6b68b04ac268e5d64b738688/aiohttp-3.11.11-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3af41686ccec6a0f2bdc66686dc0f403c41ac2089f80e2214a0f82d001052c03", size = 1743660 }, + { url = "https://files.pythonhosted.org/packages/2a/8b/0248d19dbb16b67222e75f6aecedd014656225733157e5afaf6a6a07e2e8/aiohttp-3.11.11-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:70d1f9dde0e5dd9e292a6d4d00058737052b01f3532f69c0c65818dac26dc287", size = 1785421 }, + { url = "https://files.pythonhosted.org/packages/c4/11/f478e071815a46ca0a5ae974651ff0c7a35898c55063305a896e58aa1247/aiohttp-3.11.11-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:249cc6912405917344192b9f9ea5cd5b139d49e0d2f5c7f70bdfaf6b4dbf3a2e", size = 1675145 }, + { url = "https://files.pythonhosted.org/packages/26/5d/284d182fecbb5075ae10153ff7374f57314c93a8681666600e3a9e09c505/aiohttp-3.11.11-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0eb98d90b6690827dcc84c246811feeb4e1eea683c0eac6caed7549be9c84665", size = 1619804 }, + { url = "https://files.pythonhosted.org/packages/1b/78/980064c2ad685c64ce0e8aeeb7ef1e53f43c5b005edcd7d32e60809c4992/aiohttp-3.11.11-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ec82bf1fda6cecce7f7b915f9196601a1bd1a3079796b76d16ae4cce6d0ef89b", size = 1654007 }, + { url = "https://files.pythonhosted.org/packages/21/8d/9e658d63b1438ad42b96f94da227f2e2c1d5c6001c9e8ffcc0bfb22e9105/aiohttp-3.11.11-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9fd46ce0845cfe28f108888b3ab17abff84ff695e01e73657eec3f96d72eef34", size = 1650022 }, + { url = "https://files.pythonhosted.org/packages/85/fd/a032bf7f2755c2df4f87f9effa34ccc1ef5cea465377dbaeef93bb56bbd6/aiohttp-3.11.11-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bd176afcf8f5d2aed50c3647d4925d0db0579d96f75a31e77cbaf67d8a87742d", size = 1732899 }, + { url = "https://files.pythonhosted.org/packages/c5/0c/c2b85fde167dd440c7ba50af2aac20b5a5666392b174df54c00f888c5a75/aiohttp-3.11.11-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ec2aa89305006fba9ffb98970db6c8221541be7bee4c1d027421d6f6df7d1ce2", size = 1755142 }, + { url = "https://files.pythonhosted.org/packages/bc/78/91ae1a3b3b3bed8b893c5d69c07023e151b1c95d79544ad04cf68f596c2f/aiohttp-3.11.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:92cde43018a2e17d48bb09c79e4d4cb0e236de5063ce897a5e40ac7cb4878773", size = 1692736 }, + { url = "https://files.pythonhosted.org/packages/77/89/a7ef9c4b4cdb546fcc650ca7f7395aaffbd267f0e1f648a436bec33c9b95/aiohttp-3.11.11-cp311-cp311-win32.whl", hash = "sha256:aba807f9569455cba566882c8938f1a549f205ee43c27b126e5450dc9f83cc62", size = 416418 }, + { url = "https://files.pythonhosted.org/packages/fc/db/2192489a8a51b52e06627506f8ac8df69ee221de88ab9bdea77aa793aa6a/aiohttp-3.11.11-cp311-cp311-win_amd64.whl", hash = "sha256:ae545f31489548c87b0cced5755cfe5a5308d00407000e72c4fa30b19c3220ac", size = 442509 }, +] + +[[package]] +name = "aioitertools" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/e6/888e1d726f0846c84e14a0f2f57873819eff9278b394d632aed979c98fbd/aioitertools-0.11.0.tar.gz", hash = "sha256:42c68b8dd3a69c2bf7f2233bf7df4bb58b557bca5252ac02ed5187bbc67d6831", size = 32053 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/66/d1a9fd8e6ff88f2157cb145dd054defb0fd7fe2507fe5a01347e7c690eab/aioitertools-0.11.0-py3-none-any.whl", hash = "sha256:04b95e3dab25b449def24d7df809411c10e62aab0cbe31a50ca4e68748c43394", size = 23683 }, +] + +[[package]] +name = "aiosignal" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/b5/6d55e80f6d8a08ce22b982eafa278d823b541c925f11ee774b0b9c43473d/aiosignal-1.3.2.tar.gz", hash = "sha256:a8c255c66fafb1e499c9351d0bf32ff2d8a0321595ebac3b93713656d2436f54", size = 19424 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/6a/bc7e17a3e87a2985d3e8f4da4cd0f481060eb78fb08596c42be62c90a4d9/aiosignal-1.3.2-py2.py3-none-any.whl", hash = "sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5", size = 7597 }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302 }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427 }, +] + +[[package]] +name = "antlr4-python3-runtime" +version = "4.9.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/38/7859ff46355f76f8d19459005ca000b6e7012f2f1ca597746cbcd1fbfe5e/antlr4-python3-runtime-4.9.3.tar.gz", hash = "sha256:f224469b4168294902bb1efa80a8bf7855f24c99aef99cbefc1bcd3cce77881b", size = 117034 } + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813 }, +] + +[[package]] +name = "asteval" +version = "1.0.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f0/ad92c4bc565918713f9a4b54f06d06ec370e48079fdb50cf432befabee8b/asteval-1.0.6.tar.gz", hash = "sha256:1aa8e7304b2e171a90d64dd269b648cacac4e46fe5de54ac0db24776c0c4a19f", size = 52079 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/ac/19dbba27e891f39feb4170b884da449ee2699ef4ebb88eefeda364bbbbcf/asteval-1.0.6-py3-none-any.whl", hash = "sha256:5e119ed306e39199fd99c881cea0e306b3f3807f050c9be79829fe274c6378dc", size = 22406 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233 }, +] + +[[package]] +name = "attrs" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/7c/fdf464bcc51d23881d110abd74b512a42b3d5d376a55a831b44c603ae17f/attrs-25.1.0.tar.gz", hash = "sha256:1c97078a80c814273a76b2a298a932eb681c87415c11dee0a6921de7f1b02c3e", size = 810562 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/30/d4986a882011f9df997a55e6becd864812ccfcd821d64aac8570ee39f719/attrs-25.1.0-py3-none-any.whl", hash = "sha256:c75a69e28a550a7e93789579c22aa26b0f5b83b75dc4e08fe092980051e1090a", size = 63152 }, +] + +[[package]] +name = "awscrt" +version = "0.23.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/50/0e3fd91488e5f0a18bc829869fc081cf4d9cd86642d9ee21b32907b02e80/awscrt-0.23.8.tar.gz", hash = "sha256:cba55f3ee80ea3192a0a24e84caad778570250800a59d29ef9efbcd4d1612f2f", size = 77078798 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/cc/e97c6ce9a8e78a2f5cdf5405fa3ea822eafbbd0ae6b366e88cd016509fd2/awscrt-0.23.8-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:a04770276fb458bddfb3ef22e71a46cd10fb94b21cea1a38aba69d1358049af0", size = 1471593 }, + { url = "https://files.pythonhosted.org/packages/89/e4/528d6b8dd69ab35465d028ae71a81519be76b00d1dadb99e3c5f1ed19f5b/awscrt-0.23.8-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dcbdb360b50d36c3f082211a7f518c910bf076041b0a7b06e5ea3157fcd4637", size = 8460768 }, + { url = "https://files.pythonhosted.org/packages/b0/7f/bd932351214640033c5b58ba1ab5682842f2643f686a58b7362f77fb598d/awscrt-0.23.8-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a2bb5786bcce5bb568a53b89d7a8e87f847b2c939e587377344603a62314a04", size = 8732091 }, + { url = "https://files.pythonhosted.org/packages/a8/5d/c9f01547de53bc5fad5464a0f8233885ed02f6e4822b1271a86976565b52/awscrt-0.23.8-cp311-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:811c7cfba4607bb0bf7c077a88e7443aa3a4c0f03030ec8708fba83b37acd138", size = 8544213 }, + { url = "https://files.pythonhosted.org/packages/f8/ac/f0edab94770ace1738593987ceb9b15d8e2e33519984b0857f588777f08c/awscrt-0.23.8-cp311-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:2f03fabc8a3bf50ac2481c57b107d511079b94513ae507f706c715a77fa7b0a6", size = 8931145 }, + { url = "https://files.pythonhosted.org/packages/ef/c3/4880b8d88630ef41ce8b80ec127cec23dcdcf33b21ba4b9a4a2aeff398a4/awscrt-0.23.8-cp311-abi3-win32.whl", hash = "sha256:3b2eab6b665d0fe0455d6f7c1b88c64bca3d89a584f09f2b48a543770108f994", size = 2577480 }, + { url = "https://files.pythonhosted.org/packages/e6/eb/5cd7f8ec7a350124e9fa2db438262f937d940fc6edccc4115560d75bbc08/awscrt-0.23.8-cp311-abi3-win_amd64.whl", hash = "sha256:e50f81cbcdc6e20c60250c7586d8093bedc6d8670a9fb0bc46e06e7e5032d312", size = 2630730 }, + { url = "https://files.pythonhosted.org/packages/1f/5e/746b1bebfd6c217fbd45614007f7acad44e9025f040c1869c015909995e1/awscrt-0.23.8-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:e4da3894d43909f25b25362898ff6f923355771c46708a9b61216d2a9481892e", size = 1464217 }, + { url = "https://files.pythonhosted.org/packages/de/31/b2cfb27f1be10cefa84ef755491ab6dc4e240c737ad8b77cf71ddb29a7b5/awscrt-0.23.8-cp313-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a21ea6eea206559abfc495bfbacbf7a40479d73059db1dfb6d6874aec8b6779", size = 8454142 }, + { url = "https://files.pythonhosted.org/packages/53/c5/c6d57e2a0d06b8e7b62e4221432a9d02eae2883062a3f77dd1d6217b6b52/awscrt-0.23.8-cp313-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef00ee94d0dda9a940bbb19056ab0a2ef38f3a715004c6572a79bb045fcdd42a", size = 8728618 }, + { url = "https://files.pythonhosted.org/packages/a0/2f/10278a23e34e0a266ca0d36713c181248264049769b861afcd25267ef906/awscrt-0.23.8-cp313-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:62860061d589d602d30d4a7c025d8973f8745db60630531cf8797d88891134c5", size = 8538654 }, + { url = "https://files.pythonhosted.org/packages/9b/04/6b616a21a55e9ae4511fa4f4a7c7229976fbee3f8a6eb5dea9289e3c7e00/awscrt-0.23.8-cp313-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f352f6e3876a774aa9b99b0e5c879eba404026d85f99c407ac60b0413d508366", size = 8928257 }, + { url = "https://files.pythonhosted.org/packages/bc/8e/4522bf8bbd643322b26b77f11ed8d7db95bd2d11c768b8a870fb40e77f15/awscrt-0.23.8-cp313-abi3-win32.whl", hash = "sha256:57277496ac2cebf766ffc92b8a4407b73fe846cb740c702ec87adf03c6535069", size = 2576332 }, + { url = "https://files.pythonhosted.org/packages/8f/4d/9b96fd7b39efadb47d012b018c559a8144d71b4a0ba4437ff9eccb03e9cd/awscrt-0.23.8-cp313-abi3-win_amd64.whl", hash = "sha256:2749d818559cb3398849a1ffd046591829211a76f34907c1e99088fe655e93d3", size = 2628911 }, +] + +[[package]] +name = "azure-core" +version = "1.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "six" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/51/0ee0a2844712f54117b3ee4853c3d209ba37641f0c587be22a993990989e/azure-core-1.28.0.zip", hash = "sha256:e9eefc66fc1fde56dab6f04d4e5d12c60754d5a9fa49bdcfd8534fc96ed936bd", size = 384884 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/0a/32b17d776a6bf5ddaa9dbad0e88de9d28a55bec1d37b8d408cc7d2e5e28d/azure_core-1.28.0-py3-none-any.whl", hash = "sha256:dec36dfc8eb0b052a853f30c07437effec2f9e3e1fc8f703d9bdaa5cfc0043d9", size = 185416 }, +] + +[[package]] +name = "azure-identity" +version = "1.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core" }, + { name = "cryptography" }, + { name = "msal" }, + { name = "msal-extensions" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/3e/34b445ef2f536f4710903cbc3ca33c4272ad37f676609188c4544dc8463a/azure-identity-1.13.0.zip", hash = "sha256:c931c27301ffa86b07b4dcf574e29da73e3deba9ab5d1fe4f445bb6a3117e260", size = 344692 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/16/fa96a5e057d6842e95d94fc410896e061b3d3a2584d57e13fc58268df45f/azure_identity-1.13.0-py3-none-any.whl", hash = "sha256:bd700cebb80cd9862098587c29d8677e819beca33c62568ced6d5a8e5e332b82", size = 151586 }, +] + +[[package]] +name = "azure-storage-blob" +version = "12.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core" }, + { name = "cryptography" }, + { name = "isodate" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/5f/64a471e09f064b3b3a53529ecd9ed8facfebfafff3dad7ee9350f3a00a30/azure-storage-blob-12.17.0.zip", hash = "sha256:c14b785a17050b30fc326a315bdae6bc4a078855f4f94a4c303ad74a48dc8c63", size = 698725 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/06/68c50a905e1e5481b04a6166b69fecddb87681aae7a556ab727f8e8e6f70/azure_storage_blob-12.17.0-py3-none-any.whl", hash = "sha256:0016e0c549a80282d7b4920c03f2f4ba35c53e6e3c7dbcd2a4a8c8eb3882c1e7", size = 388030 }, +] + +[[package]] +name = "boto3" +version = "1.39.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b6/2e/ed75ea3ee0fd1afacc3379bc2b7457c67a6b0f0e554e1f7ccbdbaed2351b/boto3-1.39.11.tar.gz", hash = "sha256:3027edf20642fe1d5f9dc50a420d0fe2733073ed6a9f0f047b60fe08c3682132", size = 111869 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/66/88566a6484e746c0b075f7c9bb248e8548eda0a486de4460d150a41e2d57/boto3-1.39.11-py3-none-any.whl", hash = "sha256:af8f1dad35eceff7658fab43b39b0f55892b6e3dd12308733521cc24dd2c9a02", size = 139900 }, +] + +[[package]] +name = "botocore" +version = "1.39.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/d0/9d64261186cff650fe63168441edb4f4cd33f085a74c0c54455630a71f91/botocore-1.39.11.tar.gz", hash = "sha256:953b12909d6799350e346ab038e55b6efe622c616f80aef74d7a6683ffdd972c", size = 14217749 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/2c/8a0b02d60a1dbbae7faa5af30484b016aa3023f9833dfc0d19b0b770dd6a/botocore-1.39.11-py3-none-any.whl", hash = "sha256:1545352931a8a186f3e977b1e1a4542d7d434796e274c3c62efd0210b5ea76dc", size = 13876276 }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983 }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838 }, + { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168 }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805 }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716 }, + { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569 }, + { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907 }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807 }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252 }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214 }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408 }, + { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470 }, + { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096 }, + { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941 }, +] + +[[package]] +name = "charset-normalizer" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/09/c1bc53dab74b1816a00d8d030de5bf98f724c52c1635e07681d312f20be8/charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5", size = 104809 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/77/02839016f6fbbf808e8b38601df6e0e66c17bbab76dff4613f7511413597/charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db", size = 191647 }, + { url = "https://files.pythonhosted.org/packages/3e/33/21a875a61057165e92227466e54ee076b73af1e21fe1b31f1e292251aa1e/charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96", size = 121434 }, + { url = "https://files.pythonhosted.org/packages/dd/51/68b61b90b24ca35495956b718f35a9756ef7d3dd4b3c1508056fa98d1a1b/charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e", size = 118979 }, + { url = "https://files.pythonhosted.org/packages/e4/a6/7ee57823d46331ddc37dd00749c95b0edec2c79b15fc0d6e6efb532e89ac/charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f", size = 136582 }, + { url = "https://files.pythonhosted.org/packages/74/f1/0d9fe69ac441467b737ba7f48c68241487df2f4522dd7246d9426e7c690e/charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574", size = 146645 }, + { url = "https://files.pythonhosted.org/packages/05/31/e1f51c76db7be1d4aef220d29fbfa5dbb4a99165d9833dcbf166753b6dc0/charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4", size = 139398 }, + { url = "https://files.pythonhosted.org/packages/40/26/f35951c45070edc957ba40a5b1db3cf60a9dbb1b350c2d5bef03e01e61de/charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8", size = 140273 }, + { url = "https://files.pythonhosted.org/packages/07/07/7e554f2bbce3295e191f7e653ff15d55309a9ca40d0362fcdab36f01063c/charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc", size = 142577 }, + { url = "https://files.pythonhosted.org/packages/d8/b5/eb705c313100defa57da79277d9207dc8d8e45931035862fa64b625bfead/charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae", size = 137747 }, + { url = "https://files.pythonhosted.org/packages/19/28/573147271fd041d351b438a5665be8223f1dd92f273713cb882ddafe214c/charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887", size = 143375 }, + { url = "https://files.pythonhosted.org/packages/cf/7c/f3b682fa053cc21373c9a839e6beba7705857075686a05c72e0f8c4980ca/charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae", size = 148474 }, + { url = "https://files.pythonhosted.org/packages/1e/49/7ab74d4ac537ece3bc3334ee08645e231f39f7d6df6347b29a74b0537103/charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce", size = 140232 }, + { url = "https://files.pythonhosted.org/packages/2d/dc/9dacba68c9ac0ae781d40e1a0c0058e26302ea0660e574ddf6797a0347f7/charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f", size = 140859 }, + { url = "https://files.pythonhosted.org/packages/6c/c2/4a583f800c0708dd22096298e49f887b49d9746d0e78bfc1d7e29816614c/charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab", size = 92509 }, + { url = "https://files.pythonhosted.org/packages/57/ec/80c8d48ac8b1741d5b963797b7c0c869335619e13d4744ca2f67fc11c6fc/charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77", size = 99870 }, + { url = "https://files.pythonhosted.org/packages/28/76/e6222113b83e3622caa4bb41032d0b1bf785250607392e1b778aca0b8a7d/charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc", size = 48543 }, +] + +[[package]] +name = "click" +version = "8.1.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", size = 97941 }, +] + +[[package]] +name = "cloudpickle" +version = "3.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/27/fb/576f067976d320f5f0114a8d9fa1215425441bb35627b1993e5afd8111e5/cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414", size = 22330 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "contourpy" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/c2/fc7193cc5383637ff390a712e88e4ded0452c9fbcf84abe3de5ea3df1866/contourpy-1.3.1.tar.gz", hash = "sha256:dfd97abd83335045a913e3bcc4a09c0ceadbe66580cf573fe961f4a825efa699", size = 13465753 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/bb/11250d2906ee2e8b466b5f93e6b19d525f3e0254ac8b445b56e618527718/contourpy-1.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3e8b974d8db2c5610fb4e76307e265de0edb655ae8169e8b21f41807ccbeec4b", size = 269555 }, + { url = "https://files.pythonhosted.org/packages/67/71/1e6e95aee21a500415f5d2dbf037bf4567529b6a4e986594d7026ec5ae90/contourpy-1.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:20914c8c973f41456337652a6eeca26d2148aa96dd7ac323b74516988bea89fc", size = 254549 }, + { url = "https://files.pythonhosted.org/packages/31/2c/b88986e8d79ac45efe9d8801ae341525f38e087449b6c2f2e6050468a42c/contourpy-1.3.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19d40d37c1c3a4961b4619dd9d77b12124a453cc3d02bb31a07d58ef684d3d86", size = 313000 }, + { url = "https://files.pythonhosted.org/packages/c4/18/65280989b151fcf33a8352f992eff71e61b968bef7432fbfde3a364f0730/contourpy-1.3.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:113231fe3825ebf6f15eaa8bc1f5b0ddc19d42b733345eae0934cb291beb88b6", size = 352925 }, + { url = "https://files.pythonhosted.org/packages/f5/c7/5fd0146c93220dbfe1a2e0f98969293b86ca9bc041d6c90c0e065f4619ad/contourpy-1.3.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4dbbc03a40f916a8420e420d63e96a1258d3d1b58cbdfd8d1f07b49fcbd38e85", size = 323693 }, + { url = "https://files.pythonhosted.org/packages/85/fc/7fa5d17daf77306840a4e84668a48ddff09e6bc09ba4e37e85ffc8e4faa3/contourpy-1.3.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a04ecd68acbd77fa2d39723ceca4c3197cb2969633836ced1bea14e219d077c", size = 326184 }, + { url = "https://files.pythonhosted.org/packages/ef/e7/104065c8270c7397c9571620d3ab880558957216f2b5ebb7e040f85eeb22/contourpy-1.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c414fc1ed8ee1dbd5da626cf3710c6013d3d27456651d156711fa24f24bd1291", size = 1268031 }, + { url = "https://files.pythonhosted.org/packages/e2/4a/c788d0bdbf32c8113c2354493ed291f924d4793c4a2e85b69e737a21a658/contourpy-1.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:31c1b55c1f34f80557d3830d3dd93ba722ce7e33a0b472cba0ec3b6535684d8f", size = 1325995 }, + { url = "https://files.pythonhosted.org/packages/a6/e6/a2f351a90d955f8b0564caf1ebe4b1451a3f01f83e5e3a414055a5b8bccb/contourpy-1.3.1-cp311-cp311-win32.whl", hash = "sha256:f611e628ef06670df83fce17805c344710ca5cde01edfdc72751311da8585375", size = 174396 }, + { url = "https://files.pythonhosted.org/packages/a8/7e/cd93cab453720a5d6cb75588cc17dcdc08fc3484b9de98b885924ff61900/contourpy-1.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:b2bdca22a27e35f16794cf585832e542123296b4687f9fd96822db6bae17bfc9", size = 219787 }, +] + +[[package]] +name = "coverage" +version = "7.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/d5/f809d8b630cf4c11fe490e20037a343d12a74ec2783c6cdb5aee725e7137/coverage-7.4.4.tar.gz", hash = "sha256:c901df83d097649e257e803be22592aedfd5182f07b3cc87d640bbb9afd50f49", size = 783727 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/26/e9bd37635e0e0343f41394e715725982de8811a1229ace1b3e94c9e47b86/coverage-7.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0f9f50e7ef2a71e2fae92774c99170eb8304e3fdf9c8c3c7ae9bab3e7229c5cf", size = 206305 }, + { url = "https://files.pythonhosted.org/packages/ec/1b/0c493f14813e9518ae71b8bd3061af63a332b41e6fee983996a7b90deb07/coverage-7.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:623512f8ba53c422fcfb2ce68362c97945095b864cda94a92edbaf5994201083", size = 206574 }, + { url = "https://files.pythonhosted.org/packages/64/9b/d0a8c02209f17549ce2283829b7be2b4eaef8bc7c7e0d8016774e73d54c0/coverage-7.4.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0513b9508b93da4e1716744ef6ebc507aff016ba115ffe8ecff744d1322a7b63", size = 238036 }, + { url = "https://files.pythonhosted.org/packages/0f/86/d5d971283ef625391595d79321d3f9bef09dcaa0537db665fb0d4f445c7d/coverage-7.4.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40209e141059b9370a2657c9b15607815359ab3ef9918f0196b6fccce8d3230f", size = 235610 }, + { url = "https://files.pythonhosted.org/packages/ab/1c/f8fefae78482f1998f7a9d68419b22089b5ce69a7e0fa0035827d2ce2206/coverage-7.4.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a2b2b78c78293782fd3767d53e6474582f62443d0504b1554370bde86cc8227", size = 237314 }, + { url = "https://files.pythonhosted.org/packages/5e/7c/d700521aafd6a23a61b5eb60db2f42a2306e494b3097030fcf400ce768a3/coverage-7.4.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:73bfb9c09951125d06ee473bed216e2c3742f530fc5acc1383883125de76d9cd", size = 246411 }, + { url = "https://files.pythonhosted.org/packages/95/44/c3f2e14450239fcdaff38e66a165f4aa8ac3a0753d1db33321c692558a15/coverage-7.4.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:1f384c3cc76aeedce208643697fb3e8437604b512255de6d18dae3f27655a384", size = 244786 }, + { url = "https://files.pythonhosted.org/packages/f4/ce/98e90709f9879d5834d04b49b86736118a78d848a9162333aa659c6442a7/coverage-7.4.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:54eb8d1bf7cacfbf2a3186019bcf01d11c666bd495ed18717162f7eb1e9dd00b", size = 245869 }, + { url = "https://files.pythonhosted.org/packages/a8/79/9dceb3847177d3bed1df3dd25a7672cc634369bc3cb6d2eed57ed6366a86/coverage-7.4.4-cp311-cp311-win32.whl", hash = "sha256:cac99918c7bba15302a2d81f0312c08054a3359eaa1929c7e4b26ebe41e9b286", size = 208337 }, + { url = "https://files.pythonhosted.org/packages/d0/b2/994e08535fcc094df65c00440d71a05133cc8dc0c371eecf84bbb58154f0/coverage-7.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:b14706df8b2de49869ae03a5ccbc211f4041750cd4a66f698df89d44f4bd30ec", size = 209273 }, +] + +[[package]] +name = "cryptography" +version = "44.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/4c/45dfa6829acffa344e3967d6006ee4ae8be57af746ae2eba1c431949b32c/cryptography-44.0.0.tar.gz", hash = "sha256:cd4e834f340b4293430701e772ec543b0fbe6c2dea510a5286fe0acabe153a02", size = 710657 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/09/8cc67f9b84730ad330b3b72cf867150744bf07ff113cda21a15a1c6d2c7c/cryptography-44.0.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:84111ad4ff3f6253820e6d3e58be2cc2a00adb29335d4cacb5ab4d4d34f2a123", size = 6541833 }, + { url = "https://files.pythonhosted.org/packages/7e/5b/3759e30a103144e29632e7cb72aec28cedc79e514b2ea8896bb17163c19b/cryptography-44.0.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b15492a11f9e1b62ba9d73c210e2416724633167de94607ec6069ef724fad092", size = 3922710 }, + { url = "https://files.pythonhosted.org/packages/5f/58/3b14bf39f1a0cfd679e753e8647ada56cddbf5acebffe7db90e184c76168/cryptography-44.0.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:831c3c4d0774e488fdc83a1923b49b9957d33287de923d58ebd3cec47a0ae43f", size = 4137546 }, + { url = "https://files.pythonhosted.org/packages/98/65/13d9e76ca19b0ba5603d71ac8424b5694415b348e719db277b5edc985ff5/cryptography-44.0.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:761817a3377ef15ac23cd7834715081791d4ec77f9297ee694ca1ee9c2c7e5eb", size = 3915420 }, + { url = "https://files.pythonhosted.org/packages/b1/07/40fe09ce96b91fc9276a9ad272832ead0fddedcba87f1190372af8e3039c/cryptography-44.0.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3c672a53c0fb4725a29c303be906d3c1fa99c32f58abe008a82705f9ee96f40b", size = 4154498 }, + { url = "https://files.pythonhosted.org/packages/75/ea/af65619c800ec0a7e4034207aec543acdf248d9bffba0533342d1bd435e1/cryptography-44.0.0-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4ac4c9f37eba52cb6fbeaf5b59c152ea976726b865bd4cf87883a7e7006cc543", size = 3932569 }, + { url = "https://files.pythonhosted.org/packages/c7/af/d1deb0c04d59612e3d5e54203159e284d3e7a6921e565bb0eeb6269bdd8a/cryptography-44.0.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ed3534eb1090483c96178fcb0f8893719d96d5274dfde98aa6add34614e97c8e", size = 4016721 }, + { url = "https://files.pythonhosted.org/packages/bd/69/7ca326c55698d0688db867795134bdfac87136b80ef373aaa42b225d6dd5/cryptography-44.0.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f3f6fdfa89ee2d9d496e2c087cebef9d4fcbb0ad63c40e821b39f74bf48d9c5e", size = 4240915 }, + { url = "https://files.pythonhosted.org/packages/ef/d4/cae11bf68c0f981e0413906c6dd03ae7fa864347ed5fac40021df1ef467c/cryptography-44.0.0-cp37-abi3-win32.whl", hash = "sha256:eb33480f1bad5b78233b0ad3e1b0be21e8ef1da745d8d2aecbb20671658b9053", size = 2757925 }, + { url = "https://files.pythonhosted.org/packages/64/b1/50d7739254d2002acae64eed4fc43b24ac0cc44bf0a0d388d1ca06ec5bb1/cryptography-44.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:abc998e0c0eee3c8a1904221d3f67dcfa76422b23620173e28c11d3e626c21bd", size = 3202055 }, + { url = "https://files.pythonhosted.org/packages/11/18/61e52a3d28fc1514a43b0ac291177acd1b4de00e9301aaf7ef867076ff8a/cryptography-44.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:660cb7312a08bc38be15b696462fa7cc7cd85c3ed9c576e81f4dc4d8b2b31591", size = 6542801 }, + { url = "https://files.pythonhosted.org/packages/1a/07/5f165b6c65696ef75601b781a280fc3b33f1e0cd6aa5a92d9fb96c410e97/cryptography-44.0.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1923cb251c04be85eec9fda837661c67c1049063305d6be5721643c22dd4e2b7", size = 3922613 }, + { url = "https://files.pythonhosted.org/packages/28/34/6b3ac1d80fc174812486561cf25194338151780f27e438526f9c64e16869/cryptography-44.0.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:404fdc66ee5f83a1388be54300ae978b2efd538018de18556dde92575e05defc", size = 4137925 }, + { url = "https://files.pythonhosted.org/packages/d0/c7/c656eb08fd22255d21bc3129625ed9cd5ee305f33752ef2278711b3fa98b/cryptography-44.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c5eb858beed7835e5ad1faba59e865109f3e52b3783b9ac21e7e47dc5554e289", size = 3915417 }, + { url = "https://files.pythonhosted.org/packages/ef/82/72403624f197af0db6bac4e58153bc9ac0e6020e57234115db9596eee85d/cryptography-44.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f53c2c87e0fb4b0c00fa9571082a057e37690a8f12233306161c8f4b819960b7", size = 4155160 }, + { url = "https://files.pythonhosted.org/packages/a2/cd/2f3c440913d4329ade49b146d74f2e9766422e1732613f57097fea61f344/cryptography-44.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9e6fc8a08e116fb7c7dd1f040074c9d7b51d74a8ea40d4df2fc7aa08b76b9e6c", size = 3932331 }, + { url = "https://files.pythonhosted.org/packages/7f/df/8be88797f0a1cca6e255189a57bb49237402b1880d6e8721690c5603ac23/cryptography-44.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d2436114e46b36d00f8b72ff57e598978b37399d2786fd39793c36c6d5cb1c64", size = 4017372 }, + { url = "https://files.pythonhosted.org/packages/af/36/5ccc376f025a834e72b8e52e18746b927f34e4520487098e283a719c205e/cryptography-44.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a01956ddfa0a6790d594f5b34fc1bfa6098aca434696a03cfdbe469b8ed79285", size = 4239657 }, + { url = "https://files.pythonhosted.org/packages/46/b0/f4f7d0d0bcfbc8dd6296c1449be326d04217c57afb8b2594f017eed95533/cryptography-44.0.0-cp39-abi3-win32.whl", hash = "sha256:eca27345e1214d1b9f9490d200f9db5a874479be914199194e746c893788d417", size = 2758672 }, + { url = "https://files.pythonhosted.org/packages/97/9b/443270b9210f13f6ef240eff73fd32e02d381e7103969dc66ce8e89ee901/cryptography-44.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:708ee5f1bafe76d041b53a4f95eb28cdeb8d18da17e597d46d7833ee59b97ede", size = 3202071 }, +] + +[[package]] +name = "cycler" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/34/45/a7caaacbfc2fa60bee42effc4bcc7d7c6dbe9c349500e04f65a861c15eb9/cycler-0.11.0.tar.gz", hash = "sha256:9c87405839a19696e837b3b818fed3f5f69f16f1eec1a1ad77e043dcea9c772f", size = 18784 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/f9/695d6bedebd747e5eb0fe8fad57b72fdf25411273a39791cde838d5a8f51/cycler-0.11.0-py3-none-any.whl", hash = "sha256:3a27e95f763a428a739d2add979fa7494c912a32c17c4c38c4d5f082cad165a3", size = 6389 }, +] + +[[package]] +name = "decorator" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365 }, +] + +[[package]] +name = "einops" +version = "0.8.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/77/850bef8d72ffb9219f0b1aac23fbc1bf7d038ee6ea666f331fa273031aa2/einops-0.8.2.tar.gz", hash = "sha256:609da665570e5e265e27283aab09e7f279ade90c4f01bcfca111f3d3e13f2827", size = 56261 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/09/f8d8f8f31e4483c10a906437b4ce31bdf3d6d417b73fe33f1a8b59e34228/einops-0.8.2-py3-none-any.whl", hash = "sha256:54058201ac7087911181bfec4af6091bb59380360f069276601256a76af08193", size = 65638 }, +] + +[[package]] +name = "farama-notifications" +version = "0.0.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/91/14397890dde30adc4bee6462158933806207bc5dd10d7b4d09d5c33845cf/farama_notifications-0.0.6.tar.gz", hash = "sha256:b19acac4bb41d76e59e03394b5dd165f4761c86fa327f56307a35cbee3b60158", size = 2517 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/f0/21f81892e4ed10f4ec3ef2e7cf8635fb76e7c0907c55d0da66be50094760/farama_notifications-0.0.6-py3-none-any.whl", hash = "sha256:f84839188efa1ce5bb361c2a84881b2dc2c0d0d7fb661ff00421820170930935", size = 2897 }, +] + +[[package]] +name = "fastapi" +version = "0.115.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/f5/3f921e59f189e513adb9aef826e2841672d50a399fead4e69afdeb808ff4/fastapi-0.115.7.tar.gz", hash = "sha256:0f106da6c01d88a6786b3248fb4d7a940d071f6f488488898ad5d354b25ed015", size = 293177 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/7f/bbd4dcf0faf61bc68a01939256e2ed02d681e9334c1a3cef24d5f77aba9f/fastapi-0.115.7-py3-none-any.whl", hash = "sha256:eb6a8c8bf7f26009e8147111ff15b5177a0e19bb4a45bc3486ab14804539d21e", size = 94777 }, +] + +[[package]] +name = "filelock" +version = "3.15.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/08/dd/49e06f09b6645156550fb9aee9cc1e59aba7efbc972d665a1bd6ae0435d4/filelock-3.15.4.tar.gz", hash = "sha256:2207938cbc1844345cb01a5a95524dae30f0ce089eba5b00378295a17e3e90cb", size = 18007 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/f0/48285f0262fe47103a4a45972ed2f9b93e4c80b8fd609fa98da78b2a5706/filelock-3.15.4-py3-none-any.whl", hash = "sha256:6ca1fffae96225dab4c6eaf1c4f4f28cd2568d3ec2a44e15a08520504de468e7", size = 16159 }, +] + +[[package]] +name = "flaky" +version = "3.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/c5/ef69119a01427204ff2db5fc8f98001087bcce719bbb94749dcd7b191365/flaky-3.8.1.tar.gz", hash = "sha256:47204a81ec905f3d5acfbd61daeabcada8f9d4031616d9bcb0618461729699f5", size = 25248 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/b8/b830fc43663246c3f3dd1ae7dca4847b96ed992537e85311e27fa41ac40e/flaky-3.8.1-py2.py3-none-any.whl", hash = "sha256:194ccf4f0d3a22b2de7130f4b62e45e977ac1b5ccad74d4d48f3005dcc38815e", size = 19139 }, +] + +[[package]] +name = "flatdict" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/b9/d230fe2bc5d14322fdf9b8df53b4e2902230d8a66e24cc48eeb46b29fe29/flatdict-4.0.0.tar.gz", hash = "sha256:214eb3904d932d0b1809223249ee46b74e3b31fd49ee1cbe81987a7b27733d02", size = 8160 } + +[[package]] +name = "fonttools" +version = "4.55.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/61/a300d1574dc381393424047c0396a0e213db212e28361123af9830d71a8d/fonttools-4.55.3.tar.gz", hash = "sha256:3983313c2a04d6cc1fe9251f8fc647754cf49a61dac6cb1e7249ae67afaafc45", size = 3498155 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/18/14be25545600bd100e5b74a3ac39089b7c1cb403dc513b7ca348be3381bf/fonttools-4.55.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8c4491699bad88efe95772543cd49870cf756b019ad56294f6498982408ab03e", size = 2771005 }, + { url = "https://files.pythonhosted.org/packages/b2/51/2e1a5d3871cd7c2ae2054b54e92604e7d6abc3fd3656e9583c399648fe1c/fonttools-4.55.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5323a22eabddf4b24f66d26894f1229261021dacd9d29e89f7872dd8c63f0b8b", size = 2300654 }, + { url = "https://files.pythonhosted.org/packages/73/1a/50109bb2703bc6f774b52ea081db21edf2a9fa4b6d7485faadf9d1b997e9/fonttools-4.55.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5480673f599ad410695ca2ddef2dfefe9df779a9a5cda89503881e503c9c7d90", size = 4877541 }, + { url = "https://files.pythonhosted.org/packages/5d/52/c0b9857fa075da1b8806c5dc2d8342918a8cc2065fd14fbddb3303282693/fonttools-4.55.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da9da6d65cd7aa6b0f806556f4985bcbf603bf0c5c590e61b43aa3e5a0f822d0", size = 4906304 }, + { url = "https://files.pythonhosted.org/packages/0b/1b/55f85c7e962d295e456d5209581c919620ee3e877b95cd86245187a5050f/fonttools-4.55.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e894b5bd60d9f473bed7a8f506515549cc194de08064d829464088d23097331b", size = 4888087 }, + { url = "https://files.pythonhosted.org/packages/83/13/6f2809c612ea2ac51391f92468ff861c63473601530fca96458b453212bf/fonttools-4.55.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:aee3b57643827e237ff6ec6d28d9ff9766bd8b21e08cd13bff479e13d4b14765", size = 5056958 }, + { url = "https://files.pythonhosted.org/packages/c1/28/d0ea9e872fa4208b9dfca686e1dd9ca22f6c9ef33ecff2f0ebc2dbe7c29b/fonttools-4.55.3-cp311-cp311-win32.whl", hash = "sha256:eb6ca911c4c17eb51853143624d8dc87cdcdf12a711fc38bf5bd21521e79715f", size = 2173939 }, + { url = "https://files.pythonhosted.org/packages/be/36/d74ae1020bc41a1dff3e6f5a99f646563beecb97e386d27abdac3ba07650/fonttools-4.55.3-cp311-cp311-win_amd64.whl", hash = "sha256:6314bf82c54c53c71805318fcf6786d986461622dd926d92a465199ff54b1b72", size = 2220363 }, + { url = "https://files.pythonhosted.org/packages/99/3b/406d17b1f63e04a82aa621936e6e1c53a8c05458abd66300ac85ea7f9ae9/fonttools-4.55.3-py3-none-any.whl", hash = "sha256:f412604ccbeee81b091b420272841e5ec5ef68967a9790e80bffd0e30b8e2977", size = 1111638 }, +] + +[[package]] +name = "frozenlist" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8f/ed/0f4cec13a93c02c47ec32d81d11c0c1efbadf4a471e3f3ce7cad366cbbd3/frozenlist-1.5.0.tar.gz", hash = "sha256:81d5af29e61b9c8348e876d442253723928dce6433e0e76cd925cd83f1b4b817", size = 39930 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/43/0bed28bf5eb1c9e4301003b74453b8e7aa85fb293b31dde352aac528dafc/frozenlist-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:fd74520371c3c4175142d02a976aee0b4cb4a7cc912a60586ffd8d5929979b30", size = 94987 }, + { url = "https://files.pythonhosted.org/packages/bb/bf/b74e38f09a246e8abbe1e90eb65787ed745ccab6eaa58b9c9308e052323d/frozenlist-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f3f7a0fbc219fb4455264cae4d9f01ad41ae6ee8524500f381de64ffaa077d5", size = 54584 }, + { url = "https://files.pythonhosted.org/packages/2c/31/ab01375682f14f7613a1ade30149f684c84f9b8823a4391ed950c8285656/frozenlist-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f47c9c9028f55a04ac254346e92977bf0f166c483c74b4232bee19a6697e4778", size = 52499 }, + { url = "https://files.pythonhosted.org/packages/98/a8/d0ac0b9276e1404f58fec3ab6e90a4f76b778a49373ccaf6a563f100dfbc/frozenlist-1.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0996c66760924da6e88922756d99b47512a71cfd45215f3570bf1e0b694c206a", size = 276357 }, + { url = "https://files.pythonhosted.org/packages/ad/c9/c7761084fa822f07dac38ac29f841d4587570dd211e2262544aa0b791d21/frozenlist-1.5.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2fe128eb4edeabe11896cb6af88fca5346059f6c8d807e3b910069f39157869", size = 287516 }, + { url = "https://files.pythonhosted.org/packages/a1/ff/cd7479e703c39df7bdab431798cef89dc75010d8aa0ca2514c5b9321db27/frozenlist-1.5.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a8ea951bbb6cacd492e3948b8da8c502a3f814f5d20935aae74b5df2b19cf3d", size = 283131 }, + { url = "https://files.pythonhosted.org/packages/59/a0/370941beb47d237eca4fbf27e4e91389fd68699e6f4b0ebcc95da463835b/frozenlist-1.5.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:de537c11e4aa01d37db0d403b57bd6f0546e71a82347a97c6a9f0dcc532b3a45", size = 261320 }, + { url = "https://files.pythonhosted.org/packages/b8/5f/c10123e8d64867bc9b4f2f510a32042a306ff5fcd7e2e09e5ae5100ee333/frozenlist-1.5.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c2623347b933fcb9095841f1cc5d4ff0b278addd743e0e966cb3d460278840d", size = 274877 }, + { url = "https://files.pythonhosted.org/packages/fa/79/38c505601ae29d4348f21706c5d89755ceded02a745016ba2f58bd5f1ea6/frozenlist-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cee6798eaf8b1416ef6909b06f7dc04b60755206bddc599f52232606e18179d3", size = 269592 }, + { url = "https://files.pythonhosted.org/packages/19/e2/39f3a53191b8204ba9f0bb574b926b73dd2efba2a2b9d2d730517e8f7622/frozenlist-1.5.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f5f9da7f5dbc00a604fe74aa02ae7c98bcede8a3b8b9666f9f86fc13993bc71a", size = 265934 }, + { url = "https://files.pythonhosted.org/packages/d5/c9/3075eb7f7f3a91f1a6b00284af4de0a65a9ae47084930916f5528144c9dd/frozenlist-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:90646abbc7a5d5c7c19461d2e3eeb76eb0b204919e6ece342feb6032c9325ae9", size = 283859 }, + { url = "https://files.pythonhosted.org/packages/05/f5/549f44d314c29408b962fa2b0e69a1a67c59379fb143b92a0a065ffd1f0f/frozenlist-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bdac3c7d9b705d253b2ce370fde941836a5f8b3c5c2b8fd70940a3ea3af7f4f2", size = 287560 }, + { url = "https://files.pythonhosted.org/packages/9d/f8/cb09b3c24a3eac02c4c07a9558e11e9e244fb02bf62c85ac2106d1eb0c0b/frozenlist-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03d33c2ddbc1816237a67f66336616416e2bbb6beb306e5f890f2eb22b959cdf", size = 277150 }, + { url = "https://files.pythonhosted.org/packages/37/48/38c2db3f54d1501e692d6fe058f45b6ad1b358d82cd19436efab80cfc965/frozenlist-1.5.0-cp311-cp311-win32.whl", hash = "sha256:237f6b23ee0f44066219dae14c70ae38a63f0440ce6750f868ee08775073f942", size = 45244 }, + { url = "https://files.pythonhosted.org/packages/ca/8c/2ddffeb8b60a4bce3b196c32fcc30d8830d4615e7b492ec2071da801b8ad/frozenlist-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:0cc974cc93d32c42e7b0f6cf242a6bd941c57c61b618e78b6c0a96cb72788c1d", size = 51634 }, + { url = "https://files.pythonhosted.org/packages/c6/c8/a5be5b7550c10858fcf9b0ea054baccab474da77d37f1e828ce043a3a5d4/frozenlist-1.5.0-py3-none-any.whl", hash = "sha256:d994863bba198a4a518b467bb971c56e1db3f180a25c6cf7bb1949c267f748c3", size = 11901 }, +] + +[[package]] +name = "fsspec" +version = "2024.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a0/52/f16a068ebadae42526484c31f4398e62962504e5724a8ba5dc3409483df2/fsspec-2024.10.0.tar.gz", hash = "sha256:eda2d8a4116d4f2429db8550f2457da57279247dd930bb12f821b58391359493", size = 286853 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/b2/454d6e7f0158951d8a78c2e1eb4f69ae81beb8dca5fee9809c6c99e9d0d0/fsspec-2024.10.0-py3-none-any.whl", hash = "sha256:03b9a6785766a4de40368b88906366755e2819e758b83705c88cd7cb5fe81871", size = 179641 }, +] + +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794 }, +] + +[[package]] +name = "gitpython" +version = "3.1.59" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/dc/126b28e76b24a9268ba931ad3e012f71ebdadf62fd9f17758f7074bb0b20/gitpython-3.1.59.tar.gz", hash = "sha256:0a1475cfdc38a5bfba1a3e9a4a9da52a39749ecec322b772915c019f94e5b7e4", size = 230445 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/ed/ae57eb7d344f43f87b74b3a281ead6ec7d6394eef72a7b1dcb28dd089550/gitpython-3.1.59-py3-none-any.whl", hash = "sha256:67a82f537384578643624c8b2c531938a9b82be431663e575dcf638526631d4c", size = 220996 }, +] + +[[package]] +name = "grpcio" +version = "1.83.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/98/304898ac4e04e2d5e4e4c2eadc178b1f2a16d5f4bc2f91306c87d64680b9/grpcio-1.83.0.tar.gz", hash = "sha256:7674587248fbbb2ac6e4eecf83a8a0f3d91a928f941de571acfd3a2f007fbc24", size = 13428824 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/f6/3b781cd07a715ea5f5125ae264226e7fc4d87603d6d3955022cabfdc5da2/grpcio-1.83.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:8ff0b8767ddd62704e0d9571c1890af08d84a3a689ebba1807e62519d0b3277f", size = 6338720 }, + { url = "https://files.pythonhosted.org/packages/21/cc/d14833d15d5984e366f1b027fa78bd038c9b028c66880bffb0f5a4d25ee2/grpcio-1.83.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:4772402f43517b4824980be4b3b2274a81eec0004a70009473c31b340d43e223", size = 12178773 }, + { url = "https://files.pythonhosted.org/packages/6b/98/8acbb416544e7871132d8e42a07ed70c802d70e6a16c6009e505a34d32a4/grpcio-1.83.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f4cee5fc86e84a0cf7ad1574b454c3320e087c07f55b7df5dc0ac6a873fb90c0", size = 6921203 }, + { url = "https://files.pythonhosted.org/packages/45/9c/0fdbfaf4fc54e5c88f6bce4008a065092fe7fbc4460eb5617ae8b20fd505/grpcio-1.83.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f5e822a7e7d03282f6ad225e710493c48b9057a353358344a5f7c42b2b37618d", size = 7648508 }, + { url = "https://files.pythonhosted.org/packages/f3/ea/107b9dbb2ed3ad14dd774fd3dde7d29ff9938a6c198654becb2c3a0e9a6a/grpcio-1.83.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5f410d7c2903eabb34789dfd6342eef04af1ad459943936b7e09a9f5bd417b9", size = 7079466 }, + { url = "https://files.pythonhosted.org/packages/3b/06/9fa9941089e6fae83b060b6ce61c1e81053e52decae43197245f45e07d36/grpcio-1.83.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ee94a4016fdf8699fb1fd8a38652475ff677f1c72074cee44deeeb9a7e95e745", size = 7605583 }, + { url = "https://files.pythonhosted.org/packages/a8/2f/f10fb56062dc2771c630827a82d9ad0ecd05cad572ea3b08d49f6631680a/grpcio-1.83.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c6444666317338e903093c7c756e6cc88eee59f798cb8dd41e87725bf54e1617", size = 8637810 }, + { url = "https://files.pythonhosted.org/packages/99/55/f84927258f6a1b6ea6dea661fdc6de859b35e560c96f3012d15ccd39f85e/grpcio-1.83.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:aa074041231f03959cb097dd5517b0677b8ea49215bae01d5710a7b69dd59969", size = 8008021 }, + { url = "https://files.pythonhosted.org/packages/c9/6b/cdf72161397ccd29d4ca2192f641524536c9cf54ad948c9dd0e0e01138fa/grpcio-1.83.0-cp311-cp311-win32.whl", hash = "sha256:cb056f6e171c42639a50460b2929c82241fda51f71cf3dcdd68090fe45095a45", size = 4404376 }, + { url = "https://files.pythonhosted.org/packages/df/ed/e0ffeb4c848699c194dc9fb6a29ab29bcb2b6aac8c416bf18c51bfe8242c/grpcio-1.83.0-cp311-cp311-win_amd64.whl", hash = "sha256:7416952ca770477990257206276999056f8316d79196f2f25942393e58a20b49", size = 5164469 }, +] + +[[package]] +name = "gunicorn" +version = "23.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029 }, +] + +[[package]] +name = "gymnasium" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cloudpickle" }, + { name = "farama-notifications" }, + { name = "numpy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/17/c2a0e15c2cd5a8e788389b280996db927b923410de676ec5c7b2695e9261/gymnasium-1.2.0.tar.gz", hash = "sha256:344e87561012558f603880baf264ebc97f8a5c997a957b0c9f910281145534b0", size = 821142 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/e2/a111dbb8625af467ea4760a1373d6ef27aac3137931219902406ccc05423/gymnasium-1.2.0-py3-none-any.whl", hash = "sha256:fc4a1e4121a9464c29b4d7dc6ade3fbeaa36dea448682f5f71a6d2c17489ea76", size = 944301 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, +] + +[[package]] +name = "h5py" +version = "3.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/33/acd0ce6863b6c0d7735007df01815403f5589a21ff8c2e1ee2587a38f548/h5py-3.16.0.tar.gz", hash = "sha256:a0dbaad796840ccaa67a4c144a0d0c8080073c34c76d5a6941d6818678ef2738", size = 446526 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/95/a825894f3e45cbac7554c4e97314ce886b233a20033787eda755ca8fecc7/h5py-3.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:719439d14b83f74eeb080e9650a6c7aa6d0d9ea0ca7f804347b05fac6fbf18af", size = 3721663 }, + { url = "https://files.pythonhosted.org/packages/bf/3b/38ff88b347c3e346cda1d3fc1b65a7aa75d40632228d8b8a5d7b58508c24/h5py-3.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c3f0a0e136f2e95dd0b67146abb6668af4f1a69c81ef8651a2d316e8e01de447", size = 3087630 }, + { url = "https://files.pythonhosted.org/packages/98/a8/2594cef906aee761601eff842c7dc598bea2b394a3e1c00966832b8eeb7c/h5py-3.16.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:a6fbc5367d4046801f9b7db9191b31895f22f1c6df1f9987d667854cac493538", size = 4823472 }, + { url = "https://files.pythonhosted.org/packages/52/a0/c1f604538ff6db22a0690be2dc44ab59178e115f63c917794e529356ab23/h5py-3.16.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:fb1720028d99040792bb2fb31facb8da44a6f29df7697e0b84f0d79aff2e9bd3", size = 5027150 }, + { url = "https://files.pythonhosted.org/packages/2e/fd/301739083c2fc4fd89950f9bcfce75d6e14b40b0ca3d40e48a8993d1722c/h5py-3.16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:314b6054fe0b1051c2b0cb2df5cbdab15622fb05e80f202e3b6a5eee0d6fe365", size = 4814544 }, + { url = "https://files.pythonhosted.org/packages/4c/42/2193ed41ccee78baba8fcc0cff2c925b8b9ee3793305b23e1f22c20bf4c7/h5py-3.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ffbab2fedd6581f6aa31cf1639ca2cb86e02779de525667892ebf4cc9fd26434", size = 5034013 }, + { url = "https://files.pythonhosted.org/packages/f7/20/e6c0ff62ca2ad1a396a34f4380bafccaaf8791ff8fccf3d995a1fc12d417/h5py-3.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:17d1f1630f92ad74494a9a7392ab25982ce2b469fc62da6074c0ce48366a2999", size = 3191673 }, + { url = "https://files.pythonhosted.org/packages/f2/48/239cbe352ac4f2b8243a8e620fa1a2034635f633731493a7ff1ed71e8658/h5py-3.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:85b9c49dd58dc44cf70af944784e2c2038b6f799665d0dcbbc812a26e0faa859", size = 2673834 }, +] + +[[package]] +name = "hf-xet" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/ab/522a2ab67f27971a9d48ca666d4fca85ef7d5282d142e31fd087e27b1bbe/hf_xet-1.6.0.tar.gz", hash = "sha256:2e58454a340b3556dfa4972d5451aff4fba8dd42a236600ba1a1d2b1514f0fef", size = 920527 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/50/7afa2c9c787405864fc47a0d1bbc02c62e9101947ed43c1f43899fc7d91d/hf_xet-1.6.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:633dc0cd71d32da58ab8c03ad38e2fac452c15c2b0a2866ebf6ededfe0a5061d", size = 4071729 }, + { url = "https://files.pythonhosted.org/packages/4b/69/55b8dcf636142ae660fec1869fcac14c4da2e8412e14d6eee1523be77e9f/hf_xet-1.6.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:f0906082d9932ae0c0057fa194041c22b4e2cdb46b2592ef3b91f020d62a081a", size = 3876287 }, + { url = "https://files.pythonhosted.org/packages/67/4e/a28359bf1c1ecf11eba22123168c138698f7cb576ac678f5a2e16cd5da08/hf_xet-1.6.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d62671bb130879cef0ee4c9ebe47a14af6c66ec53e6d84dc15936e5ffdfac82f", size = 4464663 }, + { url = "https://files.pythonhosted.org/packages/9a/69/1f0cbc2fb22ae6082d094f743d1b8945a3f36f6089cb95f42b7ee348cda7/hf_xet-1.6.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0e6e21fa3cdfcdcd76748564bf593870a5e013f47d97cf10aed63aa222cff5b7", size = 4262538 }, + { url = "https://files.pythonhosted.org/packages/d1/3a/4f4f2301ade26e404462d3336fa11f7958d914cabbabdd6e03c3c5d5658c/hf_xet-1.6.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4fc74352a17015bd0ee90038bc9efe38db894cde45f268b6712b04fce8cd0acb", size = 4460520 }, + { url = "https://files.pythonhosted.org/packages/ab/5f/311725e2a905534dfee2dcb5b08414f249147f1f12252bfc2bd24caa075c/hf_xet-1.6.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8fb4f71cba6129110c3374a33f919001ff130488fc23553698e34cc1c2a1198c", size = 4675937 }, + { url = "https://files.pythonhosted.org/packages/98/b7/8c59a66d15205024662f1d66968136f13893f96df1ddc5087e2e281fc95f/hf_xet-1.6.0-cp38-abi3-win_amd64.whl", hash = "sha256:fb4fadde1b2b70bf4c0c14a6dccbe7194b1c28947fefd5bbe3fed9d940676c3b", size = 4033128 }, + { url = "https://files.pythonhosted.org/packages/73/63/ca511b6f802f28cf3489b280fe77475bcca8de85e81a6299d7916b5b5555/hf_xet-1.6.0-cp38-abi3-win_arm64.whl", hash = "sha256:3dc3e35441ba395006af5aaacc40ef2e603c51ef46c3530b9156185f00935ea3", size = 3859359 }, +] + +[[package]] +name = "hidapi" +version = "0.14.0.post2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bf/6f/90c536b020a8e860f047a2839830a1ade3e1490e67336ecf489b4856eb7b/hidapi-0.14.0.post2.tar.gz", hash = "sha256:6c0e97ba6b059a309d51b495a8f0d5efbcea8756b640d98b6f6bb9fdef2458ac", size = 172542 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/ac/f6009ddff800adcaf0f57b2ec4526d9ae9419d28391e5580904ce6115ba9/hidapi-0.14.0.post2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4d3ca0ff9179bfa2337b36eaa6a4f1a3e4c8e643cf698adfef65700bffb6fe5c", size = 69984 }, + { url = "https://files.pythonhosted.org/packages/e8/1d/7a32933a022dc42d9a796843ceb05c038521318668400da5eada1ac66170/hidapi-0.14.0.post2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3b1bca2741492c67a6cb4d7b57c0db734543556b369d3604505c62a47607767f", size = 67790 }, + { url = "https://files.pythonhosted.org/packages/f5/d2/ae65b9cecc3ba140b7eff02d3f67ed56203b3715926b3f1f13d82ce7a966/hidapi-0.14.0.post2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef474da943187befd9a55270ba521f42930565b84bd59caa276adeda29669853", size = 711505 }, + { url = "https://files.pythonhosted.org/packages/af/c6/d129b74e0028ca8401ecbeffb9cd1bca377262a5dcd882ea4999ba25d416/hidapi-0.14.0.post2-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a6991f55aebd3f84cc924fd838e0052422a5a3921f5d7df0ce6c9600f09db9a", size = 726946 }, + { url = "https://files.pythonhosted.org/packages/46/07/0d773755e0c629cabd6fa10251929fbb6cba4c1936116d753e77a792eba1/hidapi-0.14.0.post2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f95340c0d69245820a2075d516df9a99c466d92db29fa78df5f13d9cfee4716d", size = 705420 }, + { url = "https://files.pythonhosted.org/packages/38/13/45cac0a2952f1cc3f5674b599d083e81cc0816e877949dad897a51925dce/hidapi-0.14.0.post2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5ebdc20915c6256c738116a3491bc5194d74233bb6a4ad7d05983dfec54a22f2", size = 338866 }, + { url = "https://files.pythonhosted.org/packages/ed/5b/25d7b9794b2017a315573464ea39617e40702acb82dec9c348a52d288433/hidapi-0.14.0.post2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:04fd0c791af679dc6de02205f5fe321e46b7cae0e8d10d7f579abb8bcca0c595", size = 328261 }, + { url = "https://files.pythonhosted.org/packages/72/79/599df7f0d40725f2456fff05b99a75a68d150916031ef0e5b01333966d8c/hidapi-0.14.0.post2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:70038c3bd26c2ec7520964d9cfe5df81928ff34a2821e83914c28a9d4d8550b7", size = 344917 }, + { url = "https://files.pythonhosted.org/packages/a2/da/148ffbeac51ca409f54568d016d67d7bc234d9cc39d4f44c1d415e7dac01/hidapi-0.14.0.post2-cp311-cp311-win32.whl", hash = "sha256:bfe65ee33f0ecafde4e742fd7c8482a914bcd0bc69c04edf40788c714eec865d", size = 56860 }, + { url = "https://files.pythonhosted.org/packages/66/7d/ed6c213a04d1eaaf1cf1962872aba9821ce5fda2498793957c288ae07c32/hidapi-0.14.0.post2-cp311-cp311-win_amd64.whl", hash = "sha256:c79d60d42b3437e0553052de108253db0e9e4a5c432996d95028d219afd4a3f3", size = 64193 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 }, +] + +[[package]] +name = "httptools" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/67/1d/d77686502fced061b3ead1c35a2d70f6b281b5f723c4eff7a2277c04e4a2/httptools-0.6.1.tar.gz", hash = "sha256:c6e26c30455600b95d94b1b836085138e82f177351454ee841c148f93a9bad5a", size = 191228 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/d1/53283b96ed823d5e4d89ee9aa0f29df5a1bdf67f148e061549a595d534e4/httptools-0.6.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7a7ea483c1a4485c71cb5f38be9db078f8b0e8b4c4dc0210f531cdd2ddac1ef1", size = 145855 }, + { url = "https://files.pythonhosted.org/packages/80/dd/cebc9d4b1d4b70e9f3d40d1db0829a28d57ca139d0b04197713816a11996/httptools-0.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:85ed077c995e942b6f1b07583e4eb0a8d324d418954fc6af913d36db7c05a5a0", size = 75604 }, + { url = "https://files.pythonhosted.org/packages/76/7a/45c5a9a2e9d21f7381866eb7b6ead5a84d8fe7e54e35208eeb18320a29b4/httptools-0.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b0bb634338334385351a1600a73e558ce619af390c2b38386206ac6a27fecfc", size = 324784 }, + { url = "https://files.pythonhosted.org/packages/59/23/047a89e66045232fb82c50ae57699e40f70e073ae5ccd53f54e532fbd2a2/httptools-0.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d9ceb2c957320def533671fc9c715a80c47025139c8d1f3797477decbc6edd2", size = 318547 }, + { url = "https://files.pythonhosted.org/packages/82/f5/50708abc7965d7d93c0ee14a148ccc6d078a508f47fe9357c79d5360f252/httptools-0.6.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4f0f8271c0a4db459f9dc807acd0eadd4839934a4b9b892f6f160e94da309837", size = 330211 }, + { url = "https://files.pythonhosted.org/packages/e3/1e/9823ca7aab323c0e0e9dd82ce835a6e93b69f69aedffbc94d31e327f4283/httptools-0.6.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6a4f5ccead6d18ec072ac0b84420e95d27c1cdf5c9f1bc8fbd8daf86bd94f43d", size = 322174 }, + { url = "https://files.pythonhosted.org/packages/14/e4/20d28dfe7f5b5603b6b04c33bb88662ad749de51f0c539a561f235f42666/httptools-0.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:5cceac09f164bcba55c0500a18fe3c47df29b62353198e4f37bbcc5d591172c3", size = 55434 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, +] + +[[package]] +name = "huggingface-hub" +version = "1.16.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tqdm" }, + { name = "typer" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/0f/ed994dbade67a54407c28cab96ef845e0e6d25500be56aca6394f8bfc9dd/huggingface_hub-1.16.1.tar.gz", hash = "sha256:7f1dc4c5ec21aed69be630ad0c3378616be16f3de1a47b141c0e812965d9c832", size = 792534 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/79/621a7dbb80c70974f73a597275351ebe03ce5bc65cb5f8f4acb5859252bc/huggingface_hub-1.16.1-py3-none-any.whl", hash = "sha256:64340de934b9ce37857ef85a82de72f5629e8a270f9119eabb12bf495eb53c22", size = 668176 }, +] + +[[package]] +name = "hydra-core" +version = "1.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "antlr4-python3-runtime" }, + { name = "omegaconf" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3e/e4/69a522676faf88994d93d8a5e69e0666c61cae7f73d1bbcc483222023e74/hydra_core-1.3.5.tar.gz", hash = "sha256:71c441eabbde086062045e4d3fce9e26015244f1a4ac721cf3e444c7edf10633", size = 3264337 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/97/f9d463a6f3c7d0955753eca5cbbf35b596ac471dd13fe357211a53fd37be/hydra_core-1.3.5-py3-none-any.whl", hash = "sha256:a3ff35b4ea6794e4c83d993016f4bde4ac35797ebe7a08f30e83ed9341880331", size = 155768 }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "idna-ssl" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/03/07c4894aae38b0de52b52586b24bf189bb83e4ddabfe2e2c8f2419eec6f4/idna-ssl-1.1.0.tar.gz", hash = "sha256:a933e3bb13da54383f9e8f35dc4f9cb9eb9b3b78c6b36f311254d6d0d92c6c7c", size = 3377 } + +[[package]] +name = "imageio" +version = "2.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/47/57e897fb7094afb2d26e8b2e4af9a45c7cf1a405acdeeca001fdf2c98501/imageio-2.37.0.tar.gz", hash = "sha256:71b57b3669666272c818497aebba2b4c5f20d5b37c81720e5e1a56d59c492996", size = 389963 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/bd/b394387b598ed84d8d0fa90611a90bee0adc2021820ad5729f7ced74a8e2/imageio-2.37.0-py3-none-any.whl", hash = "sha256:11efa15b87bc7871b61590326b2d635439acc321cf7f8ce996f812543ce10eed", size = 315796 }, +] + +[[package]] +name = "imageio-ffmpeg" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/44/bd/c3343c721f2a1b0c9fc71c1aebf1966a3b7f08c2eea8ed5437a2865611d6/imageio_ffmpeg-0.6.0.tar.gz", hash = "sha256:e2556bed8e005564a9f925bb7afa4002d82770d6b08825078b7697ab88ba1755", size = 25210 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/58/87ef68ac83f4c7690961bce288fd8e382bc5f1513860fc7f90a9c1c1c6bf/imageio_ffmpeg-0.6.0-py3-none-macosx_10_9_intel.macosx_10_9_x86_64.whl", hash = "sha256:9d2baaf867088508d4a3458e61eeb30e945c4ad8016025545f66c4b5aaef0a61", size = 24932969 }, + { url = "https://files.pythonhosted.org/packages/40/5c/f3d8a657d362cc93b81aab8feda487317da5b5d31c0e1fdfd5e986e55d17/imageio_ffmpeg-0.6.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b1ae3173414b5fc5f538a726c4e48ea97edc0d2cdc11f103afee655c463fa742", size = 21113891 }, + { url = "https://files.pythonhosted.org/packages/33/e7/1925bfbc563c39c1d2e82501d8372734a5c725e53ac3b31b4c2d081e895b/imageio_ffmpeg-0.6.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:1d47bebd83d2c5fc770720d211855f208af8a596c82d17730aa51e815cdee6dc", size = 25632706 }, + { url = "https://files.pythonhosted.org/packages/a0/2d/43c8522a2038e9d0e7dbdf3a61195ecc31ca576fb1527a528c877e87d973/imageio_ffmpeg-0.6.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:c7e46fcec401dd990405049d2e2f475e2b397779df2519b544b8aab515195282", size = 29498237 }, + { url = "https://files.pythonhosted.org/packages/a0/13/59da54728351883c3c1d9fca1710ab8eee82c7beba585df8f25ca925f08f/imageio_ffmpeg-0.6.0-py3-none-win32.whl", hash = "sha256:196faa79366b4a82f95c0f4053191d2013f4714a715780f0ad2a68ff37483cc2", size = 19652251 }, + { url = "https://files.pythonhosted.org/packages/2c/c6/fa760e12a2483469e2bf5058c5faff664acf66cadb4df2ad6205b016a73d/imageio_ffmpeg-0.6.0-py3-none-win_amd64.whl", hash = "sha256:02fa47c83703c37df6bfe4896aab339013f62bf02c5ebf2dce6da56af04ffc0a", size = 31246824 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 }, +] + +[[package]] +name = "isaaclab" +version = "2.3.2.post1" +source = { registry = "https://pypi.nvidia.com/" } +dependencies = [ + { name = "einops" }, + { name = "flaky" }, + { name = "flatdict" }, + { name = "gymnasium" }, + { name = "h5py" }, + { name = "hidapi" }, + { name = "hydra-core" }, + { name = "junitparser" }, + { name = "moviepy" }, + { name = "numba" }, + { name = "numpy" }, + { name = "onnx" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "prettytable" }, + { name = "protobuf" }, + { name = "pyglet" }, + { name = "pytest" }, + { name = "pytest-mock" }, + { name = "starlette" }, + { name = "tensorboard" }, + { name = "toml" }, + { name = "torch" }, + { name = "torchvision", version = "0.22.0", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, + { name = "torchvision", version = "0.22.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "transformers" }, + { name = "trimesh" }, + { name = "warp-lang" }, +] +wheels = [ + { url = "https://pypi.nvidia.com/isaaclab/isaaclab-2.3.2.post1-cp311-none-manylinux_2_35_aarch64.whl", hash = "sha256:befacd8fad86a717c1152e70da2a2895b2716000bcf17c9d128bdf072619a77c" }, + { url = "https://pypi.nvidia.com/isaaclab/isaaclab-2.3.2.post1-cp311-none-manylinux_2_35_x86_64.whl", hash = "sha256:53f085b5c8377697d52cb404fb6ca0902771983f790f0839a13709a67cea1919" }, + { url = "https://pypi.nvidia.com/isaaclab/isaaclab-2.3.2.post1-cp311-none-win_amd64.whl", hash = "sha256:0e8d8fa5d9147fd13e983bc7c17f98843eca794d358ff6f239e30ff433682b2f" }, +] + +[package.optional-dependencies] +all = [ + { name = "onnxscript" }, + { name = "rich" }, + { name = "rsl-rl-lib" }, + { name = "skrl" }, + { name = "stable-baselines3" }, + { name = "tqdm" }, +] +isaacsim = [ + { name = "isaacsim", extra = ["all", "extscache"] }, +] + +[[package]] +name = "isaacsim" +version = "5.1.0.0" +source = { registry = "https://pypi.nvidia.com/" } +dependencies = [ + { name = "isaacsim-kernel" }, +] +wheels = [ + { url = "https://pypi.nvidia.com/isaacsim/isaacsim-5.1.0.0-cp311-none-manylinux_2_35_aarch64.whl", hash = "sha256:8304782e5161b9baac80374a5c00dbf9e2694380447eba2e85e70688358c4557" }, + { url = "https://pypi.nvidia.com/isaacsim/isaacsim-5.1.0.0-cp311-none-manylinux_2_35_x86_64.whl", hash = "sha256:ad2c027831ed5d4a62552735bb799dea4e4604530d2ab9b526ddb6cd19a98c11" }, + { url = "https://pypi.nvidia.com/isaacsim/isaacsim-5.1.0.0-cp311-none-win_amd64.whl", hash = "sha256:f2f4cbc13594749deb5905aebdf76ac68c3e5caef5db88be941b18735a889751" }, +] + +[package.optional-dependencies] +all = [ + { name = "isaacsim-app" }, + { name = "isaacsim-asset" }, + { name = "isaacsim-benchmark" }, + { name = "isaacsim-code-editor" }, + { name = "isaacsim-core" }, + { name = "isaacsim-cortex" }, + { name = "isaacsim-example" }, + { name = "isaacsim-gui" }, + { name = "isaacsim-replicator" }, + { name = "isaacsim-rl" }, + { name = "isaacsim-robot" }, + { name = "isaacsim-robot-motion" }, + { name = "isaacsim-robot-setup" }, + { name = "isaacsim-ros1" }, + { name = "isaacsim-ros2" }, + { name = "isaacsim-sensor" }, + { name = "isaacsim-storage" }, + { name = "isaacsim-template" }, + { name = "isaacsim-test" }, + { name = "isaacsim-utils" }, +] +extscache = [ + { name = "isaacsim-extscache-kit" }, + { name = "isaacsim-extscache-kit-sdk" }, + { name = "isaacsim-extscache-physics" }, +] + +[[package]] +name = "isaacsim-app" +version = "5.1.0.0" +source = { registry = "https://pypi.nvidia.com/" } +dependencies = [ + { name = "isaacsim-kernel" }, +] +wheels = [ + { url = "https://pypi.nvidia.com/isaacsim-app/isaacsim_app-5.1.0.0-cp311-none-manylinux_2_35_aarch64.whl", hash = "sha256:0502033cdac79f277652c5955d52010a45e7fa00d9ea9562acc20e9a7917e5a5" }, + { url = "https://pypi.nvidia.com/isaacsim-app/isaacsim_app-5.1.0.0-cp311-none-manylinux_2_35_x86_64.whl", hash = "sha256:88d0ad9d56439aacd670490dab7cf55c72cece72894e3932a9a0493b12fa60a2" }, + { url = "https://pypi.nvidia.com/isaacsim-app/isaacsim_app-5.1.0.0-cp311-none-win_amd64.whl", hash = "sha256:4bf4ef7818c624638d1ee6cd7669d99fb94eb4794c20007c676f812b1f8fa234" }, +] + +[[package]] +name = "isaacsim-asset" +version = "5.1.0.0" +source = { registry = "https://pypi.nvidia.com/" } +dependencies = [ + { name = "isaacsim-storage" }, + { name = "isaacsim-utils" }, +] +wheels = [ + { url = "https://pypi.nvidia.com/isaacsim-asset/isaacsim_asset-5.1.0.0-cp311-none-manylinux_2_35_aarch64.whl", hash = "sha256:86aa9d2ac933d33ed15682eface2b792d5825e9c5e13bd3a479e29502018fe98" }, + { url = "https://pypi.nvidia.com/isaacsim-asset/isaacsim_asset-5.1.0.0-cp311-none-manylinux_2_35_x86_64.whl", hash = "sha256:260bb3486e263488fd406c28da37ef79d73da7fd9f9ea6c7a36b111a8a6f8361" }, + { url = "https://pypi.nvidia.com/isaacsim-asset/isaacsim_asset-5.1.0.0-cp311-none-win_amd64.whl", hash = "sha256:3d0d686168fe156e966c401ffa8ba5965d566ce7a7d1b8a9a4b396575e5fbb43" }, +] + +[[package]] +name = "isaacsim-benchmark" +version = "5.1.0.0" +source = { registry = "https://pypi.nvidia.com/" } +dependencies = [ + { name = "isaacsim-robot" }, +] +wheels = [ + { url = "https://pypi.nvidia.com/isaacsim-benchmark/isaacsim_benchmark-5.1.0.0-cp311-none-manylinux_2_35_aarch64.whl", hash = "sha256:13000151fe7f8f715a4d4268231c9d1bcba1f61b40707ee8af2dd48e852c77b8" }, + { url = "https://pypi.nvidia.com/isaacsim-benchmark/isaacsim_benchmark-5.1.0.0-cp311-none-manylinux_2_35_x86_64.whl", hash = "sha256:8353d696c1231524ac263b5700f79fe5f9b8c9dd0ef003580fec5d9c166bc5b3" }, + { url = "https://pypi.nvidia.com/isaacsim-benchmark/isaacsim_benchmark-5.1.0.0-cp311-none-win_amd64.whl", hash = "sha256:43a41b2f82c31e8da06a28f1c0b4b1a1735e170af2ee77b566c855fa309cd617" }, +] + +[[package]] +name = "isaacsim-code-editor" +version = "5.1.0.0" +source = { registry = "https://pypi.nvidia.com/" } +dependencies = [ + { name = "isaacsim-kernel" }, +] +wheels = [ + { url = "https://pypi.nvidia.com/isaacsim-code-editor/isaacsim_code_editor-5.1.0.0-cp311-none-manylinux_2_35_aarch64.whl", hash = "sha256:209d1aad3504294d98a1318a415b98fe4a794865d0729da817cb91ac5b11a462" }, + { url = "https://pypi.nvidia.com/isaacsim-code-editor/isaacsim_code_editor-5.1.0.0-cp311-none-manylinux_2_35_x86_64.whl", hash = "sha256:4dbc1833765987aa614e78f4166ef1dcddd5282fe4f395ffc5e3b6ebee653b10" }, + { url = "https://pypi.nvidia.com/isaacsim-code-editor/isaacsim_code_editor-5.1.0.0-cp311-none-win_amd64.whl", hash = "sha256:8dbb5a845947f9584494dc5defce1b1c539056bd7cb81a03c55dd41897d06ead" }, +] + +[[package]] +name = "isaacsim-core" +version = "5.1.0.0" +source = { registry = "https://pypi.nvidia.com/" } +dependencies = [ + { name = "aioboto3" }, + { name = "aiobotocore" }, + { name = "awscrt" }, + { name = "azure-core" }, + { name = "azure-identity" }, + { name = "azure-storage-blob" }, + { name = "boto3" }, + { name = "botocore" }, + { name = "contourpy" }, + { name = "cryptography" }, + { name = "cycler" }, + { name = "filelock" }, + { name = "fonttools" }, + { name = "fsspec" }, + { name = "gunicorn" }, + { name = "imageio" }, + { name = "isaacsim-kernel" }, + { name = "isodate" }, + { name = "jmespath" }, + { name = "kiwisolver" }, + { name = "llvmlite" }, + { name = "markupsafe" }, + { name = "matplotlib" }, + { name = "mpmath" }, + { name = "msal" }, + { name = "msal-extensions" }, + { name = "nest-asyncio" }, + { name = "networkx" }, + { name = "numba" }, + { name = "oauthlib" }, + { name = "opencv-python-headless" }, + { name = "osqp" }, + { name = "packaging" }, + { name = "pint" }, + { name = "portalocker" }, + { name = "pyparsing" }, + { name = "pyperclip" }, + { name = "python-dateutil" }, + { name = "pyyaml" }, + { name = "qdldl" }, + { name = "requests-oauthlib" }, + { name = "rtree" }, + { name = "s3transfer" }, + { name = "scipy" }, + { name = "six" }, + { name = "sympy" }, + { name = "torch" }, + { name = "torchaudio" }, + { name = "torchvision", version = "0.22.0", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, + { name = "torchvision", version = "0.22.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "tornado" }, + { name = "trimesh" }, +] +wheels = [ + { url = "https://pypi.nvidia.com/isaacsim-core/isaacsim_core-5.1.0.0-cp311-none-manylinux_2_35_aarch64.whl", hash = "sha256:6a231df29a80cba1b5d65fc54500df9f2eac2d75688e9e579d0cfc855faaa151" }, + { url = "https://pypi.nvidia.com/isaacsim-core/isaacsim_core-5.1.0.0-cp311-none-manylinux_2_35_x86_64.whl", hash = "sha256:cfdd5d82ce455d654c68d37ff4fdd062190e3414289beb6f9b876a10a851b92d" }, + { url = "https://pypi.nvidia.com/isaacsim-core/isaacsim_core-5.1.0.0-cp311-none-win_amd64.whl", hash = "sha256:2a5565e4d8dff8ad2c82298900b887c93e5e3548e00c4da587edc0eb05fcd6a9" }, +] + +[[package]] +name = "isaacsim-cortex" +version = "5.1.0.0" +source = { registry = "https://pypi.nvidia.com/" } +dependencies = [ + { name = "isaacsim-robot" }, + { name = "isaacsim-ros1" }, +] +wheels = [ + { url = "https://pypi.nvidia.com/isaacsim-cortex/isaacsim_cortex-5.1.0.0-cp311-none-manylinux_2_35_aarch64.whl", hash = "sha256:5b641e9fe12ee66dee15b9e63ffebb2c605d285e3ef9aab8113e361a052ffe4f" }, + { url = "https://pypi.nvidia.com/isaacsim-cortex/isaacsim_cortex-5.1.0.0-cp311-none-manylinux_2_35_x86_64.whl", hash = "sha256:b2f9b7f2f4aa8b856bb7a5f7808aec90f633b2ee409e709099a0d071c57a6296" }, + { url = "https://pypi.nvidia.com/isaacsim-cortex/isaacsim_cortex-5.1.0.0-cp311-none-win_amd64.whl", hash = "sha256:7779529c2d53d97643cd3ae57aecc2d805765c59ab94dc24d29e6ea680583469" }, +] + +[[package]] +name = "isaacsim-example" +version = "5.1.0.0" +source = { registry = "https://pypi.nvidia.com/" } +dependencies = [ + { name = "isaacsim-cortex" }, +] +wheels = [ + { url = "https://pypi.nvidia.com/isaacsim-example/isaacsim_example-5.1.0.0-cp311-none-manylinux_2_35_aarch64.whl", hash = "sha256:9dc0f7bbe5a90f2ebb185315ae0632fb176e987e437ff3fa911efce47f0a38c0" }, + { url = "https://pypi.nvidia.com/isaacsim-example/isaacsim_example-5.1.0.0-cp311-none-manylinux_2_35_x86_64.whl", hash = "sha256:a537336402f631921da4bee742ad846949824d48c84dacfbc7e8fd97b2d23c04" }, + { url = "https://pypi.nvidia.com/isaacsim-example/isaacsim_example-5.1.0.0-cp311-none-win_amd64.whl", hash = "sha256:e16a149c50514d6b3157b8267efe7a24abe72637fc8967311b097e7538fd772a" }, +] + +[[package]] +name = "isaacsim-extscache-kit" +version = "5.1.0.0" +source = { registry = "https://pypi.nvidia.com/" } +wheels = [ + { url = "https://pypi.nvidia.com/isaacsim-extscache-kit/isaacsim_extscache_kit-5.1.0.0-cp311-none-manylinux_2_35_aarch64.whl", hash = "sha256:da2ef7a83c8f5050b868e39672bc6023474ddb9ddbfcc830c9554b5431548e53" }, + { url = "https://pypi.nvidia.com/isaacsim-extscache-kit/isaacsim_extscache_kit-5.1.0.0-cp311-none-manylinux_2_35_x86_64.whl", hash = "sha256:82c0c43a4a8a62a37c82a6c32a489be6ac2fb52baf17041dcccb80295d94721f" }, + { url = "https://pypi.nvidia.com/isaacsim-extscache-kit/isaacsim_extscache_kit-5.1.0.0-cp311-none-win_amd64.whl", hash = "sha256:8c1e752426d202159c49b2874e21a23b0a57a86969b8abaf8064c1d1ff9e64aa" }, +] + +[[package]] +name = "isaacsim-extscache-kit-sdk" +version = "5.1.0.0" +source = { registry = "https://pypi.nvidia.com/" } +wheels = [ + { url = "https://pypi.nvidia.com/isaacsim-extscache-kit-sdk/isaacsim_extscache_kit_sdk-5.1.0.0-cp311-none-manylinux_2_35_aarch64.whl", hash = "sha256:d56743f50eb364ff3c076a85c466fa834afa145fffb73cab660b88ab07a00013" }, + { url = "https://pypi.nvidia.com/isaacsim-extscache-kit-sdk/isaacsim_extscache_kit_sdk-5.1.0.0-cp311-none-manylinux_2_35_x86_64.whl", hash = "sha256:be8cb4520eb9c97c4ddd46e1c92885bb9e5dbc581e407930f186a3808eafc038" }, + { url = "https://pypi.nvidia.com/isaacsim-extscache-kit-sdk/isaacsim_extscache_kit_sdk-5.1.0.0-cp311-none-win_amd64.whl", hash = "sha256:16953730a0f93df6e36ef454fe2a783658c949e9ac8e7efeb5fdbf8c41ce5d29" }, +] + +[[package]] +name = "isaacsim-extscache-physics" +version = "5.1.0.0" +source = { registry = "https://pypi.nvidia.com/" } +wheels = [ + { url = "https://pypi.nvidia.com/isaacsim-extscache-physics/isaacsim_extscache_physics-5.1.0.0-cp311-none-manylinux_2_35_aarch64.whl", hash = "sha256:5210318a6c82ced752ac4210ec0c09960d26cdb3cc3ccb110c2f4c5e91d9573f" }, + { url = "https://pypi.nvidia.com/isaacsim-extscache-physics/isaacsim_extscache_physics-5.1.0.0-cp311-none-manylinux_2_35_x86_64.whl", hash = "sha256:a4b450c7b33d2ada42e1736ac48e002103aac0b93e4f618d9fa4c212762dc74b" }, + { url = "https://pypi.nvidia.com/isaacsim-extscache-physics/isaacsim_extscache_physics-5.1.0.0-cp311-none-win_amd64.whl", hash = "sha256:e0051cdc1fd464355ce5aca07c7b2e03926854f14fe6830e30f51522ef03d97d" }, +] + +[[package]] +name = "isaacsim-gui" +version = "5.1.0.0" +source = { registry = "https://pypi.nvidia.com/" } +dependencies = [ + { name = "isaacsim-core" }, +] +wheels = [ + { url = "https://pypi.nvidia.com/isaacsim-gui/isaacsim_gui-5.1.0.0-cp311-none-manylinux_2_35_aarch64.whl", hash = "sha256:90596bcd7daf5d67f3c37a75e2ae863e8e00d504bd33e68172aae07ddbcbc222" }, + { url = "https://pypi.nvidia.com/isaacsim-gui/isaacsim_gui-5.1.0.0-cp311-none-manylinux_2_35_x86_64.whl", hash = "sha256:af0a3e2eb9ecd6c5a6088fcd50a480736c391f72bd38a854cc69a1c13861800b" }, + { url = "https://pypi.nvidia.com/isaacsim-gui/isaacsim_gui-5.1.0.0-cp311-none-win_amd64.whl", hash = "sha256:28afb4c0697ac41382f26055ae1e5885e72755f1cc18784250450ab48ea01aad" }, +] + +[[package]] +name = "isaacsim-kernel" +version = "5.1.0.0" +source = { registry = "https://pypi.nvidia.com/" } +dependencies = [ + { name = "aiodns" }, + { name = "aiofiles" }, + { name = "aiohappyeyeballs" }, + { name = "aiohttp" }, + { name = "aioitertools" }, + { name = "aiosignal" }, + { name = "asteval" }, + { name = "async-timeout" }, + { name = "attrs" }, + { name = "charset-normalizer" }, + { name = "click" }, + { name = "coverage" }, + { name = "fastapi" }, + { name = "frozenlist" }, + { name = "httptools" }, + { name = "idna" }, + { name = "idna-ssl" }, + { name = "jinja2" }, + { name = "multidict" }, + { name = "numpy" }, + { name = "pillow" }, + { name = "propcache" }, + { name = "psutil" }, + { name = "pycares" }, + { name = "python-multipart" }, + { name = "pytz" }, + { name = "qrcode" }, + { name = "requests" }, + { name = "sentry-sdk" }, + { name = "toml" }, + { name = "typing-extensions" }, + { name = "uvicorn" }, + { name = "watchdog" }, + { name = "websockets" }, + { name = "wrapt" }, + { name = "yarl" }, +] +wheels = [ + { url = "https://pypi.nvidia.com/isaacsim-kernel/isaacsim_kernel-5.1.0.0-cp311-none-manylinux_2_35_aarch64.whl", hash = "sha256:d924c9f7b1859ce0acffaafe9ad5af2d4f1e71d1f6c01ff2a7809cb6f5d97214" }, + { url = "https://pypi.nvidia.com/isaacsim-kernel/isaacsim_kernel-5.1.0.0-cp311-none-manylinux_2_35_x86_64.whl", hash = "sha256:f981290a70ad89f169fb9c47bd55833665677879ebc46ed3992756c617ac02fc" }, + { url = "https://pypi.nvidia.com/isaacsim-kernel/isaacsim_kernel-5.1.0.0-cp311-none-win_amd64.whl", hash = "sha256:b82831156c88ee022837f440d3a4298d011a204c5ab64e5bde837164e395b397" }, +] + +[[package]] +name = "isaacsim-replicator" +version = "5.1.0.0" +source = { registry = "https://pypi.nvidia.com/" } +dependencies = [ + { name = "isaacsim-core" }, + { name = "isaacsim-storage" }, +] +wheels = [ + { url = "https://pypi.nvidia.com/isaacsim-replicator/isaacsim_replicator-5.1.0.0-cp311-none-manylinux_2_35_aarch64.whl", hash = "sha256:895579ed8f4c518733911221aa568695648d3289740e7d060fd081687e8b6650" }, + { url = "https://pypi.nvidia.com/isaacsim-replicator/isaacsim_replicator-5.1.0.0-cp311-none-manylinux_2_35_x86_64.whl", hash = "sha256:8fe63ec5adf9fbe12aef82b4634a9662f8ad933d8f8428c64f11dc71406e4948" }, + { url = "https://pypi.nvidia.com/isaacsim-replicator/isaacsim_replicator-5.1.0.0-cp311-none-win_amd64.whl", hash = "sha256:aaabd003ed80e4af79622c1547dfab3054f169cb20a705027bd0108213334777" }, +] + +[[package]] +name = "isaacsim-rl" +version = "5.1.0.0" +source = { registry = "https://pypi.nvidia.com/" } +dependencies = [ + { name = "isaacsim-benchmark" }, + { name = "isaacsim-robot" }, +] +wheels = [ + { url = "https://pypi.nvidia.com/isaacsim-rl/isaacsim_rl-5.1.0.0-cp311-none-manylinux_2_35_aarch64.whl", hash = "sha256:71352f291cc86b4afe8205af97dc28778358f7481f588eedccbfddf8e50908cb" }, + { url = "https://pypi.nvidia.com/isaacsim-rl/isaacsim_rl-5.1.0.0-cp311-none-manylinux_2_35_x86_64.whl", hash = "sha256:d1fdc4f2046a205ba34a3a69c96f5a51744783f74cd824e047f43bc1ee66c7b9" }, + { url = "https://pypi.nvidia.com/isaacsim-rl/isaacsim_rl-5.1.0.0-cp311-none-win_amd64.whl", hash = "sha256:6a2bb5ef88d77910ed221658b4848089ebd16f08a38cd73b352d9f4098c6ba8f" }, +] + +[[package]] +name = "isaacsim-robot" +version = "5.1.0.0" +source = { registry = "https://pypi.nvidia.com/" } +dependencies = [ + { name = "isaacsim-robot-motion" }, + { name = "isaacsim-sensor" }, +] +wheels = [ + { url = "https://pypi.nvidia.com/isaacsim-robot/isaacsim_robot-5.1.0.0-cp311-none-manylinux_2_35_aarch64.whl", hash = "sha256:f27e2c14309831109027420a27dc92f253af18a8e29826a0fbc8155dfa35a40d" }, + { url = "https://pypi.nvidia.com/isaacsim-robot/isaacsim_robot-5.1.0.0-cp311-none-manylinux_2_35_x86_64.whl", hash = "sha256:3d7529f76b390210fd3dad66101958f4292f7306c9182b06cf532763341087e2" }, + { url = "https://pypi.nvidia.com/isaacsim-robot/isaacsim_robot-5.1.0.0-cp311-none-win_amd64.whl", hash = "sha256:80cc1561f1a34ee3f65c86ec0573c3eddab20f00a56b42757156dbeed0434fb7" }, +] + +[[package]] +name = "isaacsim-robot-motion" +version = "5.1.0.0" +source = { registry = "https://pypi.nvidia.com/" } +dependencies = [ + { name = "isaacsim-gui" }, +] +wheels = [ + { url = "https://pypi.nvidia.com/isaacsim-robot-motion/isaacsim_robot_motion-5.1.0.0-cp311-none-manylinux_2_35_aarch64.whl", hash = "sha256:15d7da27b148e565ae95112a1caabdea7d1d7b2ac6a2b2d0465914c19656282f" }, + { url = "https://pypi.nvidia.com/isaacsim-robot-motion/isaacsim_robot_motion-5.1.0.0-cp311-none-manylinux_2_35_x86_64.whl", hash = "sha256:0163f3d3243f0641502527f066febe22c93e651eb54e911442f88bc3fb37db9c" }, + { url = "https://pypi.nvidia.com/isaacsim-robot-motion/isaacsim_robot_motion-5.1.0.0-cp311-none-win_amd64.whl", hash = "sha256:0b61feb94e8bb8b59e14820169283e878872d1b1969df60ebbd1d8ae40fb15cf" }, +] + +[[package]] +name = "isaacsim-robot-setup" +version = "5.1.0.0" +source = { registry = "https://pypi.nvidia.com/" } +dependencies = [ + { name = "isaacsim-robot-motion" }, +] +wheels = [ + { url = "https://pypi.nvidia.com/isaacsim-robot-setup/isaacsim_robot_setup-5.1.0.0-cp311-none-manylinux_2_35_aarch64.whl", hash = "sha256:3601191be6f34618dd56dd05375df5002ac08b4ce44e701b456b682e3964bcd2" }, + { url = "https://pypi.nvidia.com/isaacsim-robot-setup/isaacsim_robot_setup-5.1.0.0-cp311-none-manylinux_2_35_x86_64.whl", hash = "sha256:9d48262259e89cb1d02c325d69cca6ece24919d4736f8a5ac3db861ddb962de0" }, + { url = "https://pypi.nvidia.com/isaacsim-robot-setup/isaacsim_robot_setup-5.1.0.0-cp311-none-win_amd64.whl", hash = "sha256:a0680508fbfc37e0d87654b48ff5ebfe786ceaae7cb1d987e7d68f41700f16dd" }, +] + +[[package]] +name = "isaacsim-ros1" +version = "5.1.0.0" +source = { registry = "https://pypi.nvidia.com/" } +dependencies = [ + { name = "isaacsim-sensor" }, +] +wheels = [ + { url = "https://pypi.nvidia.com/isaacsim-ros1/isaacsim_ros1-5.1.0.0-cp311-none-manylinux_2_35_aarch64.whl", hash = "sha256:cf3c075164e98fbc002f47d1547d19ff488447e7fd13b9cf5e19537aa948c537" }, + { url = "https://pypi.nvidia.com/isaacsim-ros1/isaacsim_ros1-5.1.0.0-cp311-none-manylinux_2_35_x86_64.whl", hash = "sha256:a0b541c266c409b7e48388f2f2bfce8a0417735b4fb238dd0b1dbf9f1a9df06e" }, + { url = "https://pypi.nvidia.com/isaacsim-ros1/isaacsim_ros1-5.1.0.0-cp311-none-win_amd64.whl", hash = "sha256:413075524a7a368372ee3072152c62c0f85d4251509888517f6e8d4fa0e260ad" }, +] + +[[package]] +name = "isaacsim-ros2" +version = "5.1.0.0" +source = { registry = "https://pypi.nvidia.com/" } +dependencies = [ + { name = "isaacsim-sensor" }, +] +wheels = [ + { url = "https://pypi.nvidia.com/isaacsim-ros2/isaacsim_ros2-5.1.0.0-cp311-none-manylinux_2_35_aarch64.whl", hash = "sha256:ebfc3eff445e8245d0f3446bc95c1ae4ea6194872af8c78b8d2fc4a2a1df7902" }, + { url = "https://pypi.nvidia.com/isaacsim-ros2/isaacsim_ros2-5.1.0.0-cp311-none-manylinux_2_35_x86_64.whl", hash = "sha256:0b9ee70c64a54a8ee416de2a785b71270832c15b901f86adf6bd18153dc66662" }, + { url = "https://pypi.nvidia.com/isaacsim-ros2/isaacsim_ros2-5.1.0.0-cp311-none-win_amd64.whl", hash = "sha256:ccbe7d3f9ec5b3b915a0f645e751f10dfff79f810a6e5bcfdde4e311d2c96e98" }, +] + +[[package]] +name = "isaacsim-sensor" +version = "5.1.0.0" +source = { registry = "https://pypi.nvidia.com/" } +dependencies = [ + { name = "isaacsim-gui" }, + { name = "isaacsim-storage" }, + { name = "isaacsim-utils" }, +] +wheels = [ + { url = "https://pypi.nvidia.com/isaacsim-sensor/isaacsim_sensor-5.1.0.0-cp311-none-manylinux_2_35_aarch64.whl", hash = "sha256:bd846b7d2c4fdf7479749d3ba00b2f21ebcd3a18551dd9624d52e954036f026c" }, + { url = "https://pypi.nvidia.com/isaacsim-sensor/isaacsim_sensor-5.1.0.0-cp311-none-manylinux_2_35_x86_64.whl", hash = "sha256:3c52d04aa11a213103031a3a53f2a74866a27dc47fd88bbd3eb6c52c65f163f1" }, + { url = "https://pypi.nvidia.com/isaacsim-sensor/isaacsim_sensor-5.1.0.0-cp311-none-win_amd64.whl", hash = "sha256:02becac807d15b9a7bcb61ca1f7312b8d848e2b0a9fcb02cd827d34e04feaa04" }, +] + +[[package]] +name = "isaacsim-storage" +version = "5.1.0.0" +source = { registry = "https://pypi.nvidia.com/" } +dependencies = [ + { name = "isaacsim-kernel" }, +] +wheels = [ + { url = "https://pypi.nvidia.com/isaacsim-storage/isaacsim_storage-5.1.0.0-cp311-none-manylinux_2_35_aarch64.whl", hash = "sha256:fa425e5b9bb143294862f6e00b62d007dfdf4602894ca0ec2b127d7689f3f983" }, + { url = "https://pypi.nvidia.com/isaacsim-storage/isaacsim_storage-5.1.0.0-cp311-none-manylinux_2_35_x86_64.whl", hash = "sha256:57d90579ef9ce8e89e39bd38f30f81577a051c19e5950e8d4a67c3af894fd625" }, + { url = "https://pypi.nvidia.com/isaacsim-storage/isaacsim_storage-5.1.0.0-cp311-none-win_amd64.whl", hash = "sha256:ca2e7f0d7b0af4739334e4b192f4fca686de7b1b650d591d3a3217a252b18408" }, +] + +[[package]] +name = "isaacsim-template" +version = "5.1.0.0" +source = { registry = "https://pypi.nvidia.com/" } +dependencies = [ + { name = "isaacsim-gui" }, + { name = "isaacsim-storage" }, +] +wheels = [ + { url = "https://pypi.nvidia.com/isaacsim-template/isaacsim_template-5.1.0.0-cp311-none-manylinux_2_35_aarch64.whl", hash = "sha256:83a1900e5823a3efae468729995e34a96cfc25df81c9a30a5d4cd1ffddf54aa9" }, + { url = "https://pypi.nvidia.com/isaacsim-template/isaacsim_template-5.1.0.0-cp311-none-manylinux_2_35_x86_64.whl", hash = "sha256:76e19875380d9e5432a01b3b14d2d640d1d092069518e36c597eb79ff1a06474" }, + { url = "https://pypi.nvidia.com/isaacsim-template/isaacsim_template-5.1.0.0-cp311-none-win_amd64.whl", hash = "sha256:806c850d3670e3ccf1f611c355a7e779555979bea88a42eda33fe290785c12f6" }, +] + +[[package]] +name = "isaacsim-test" +version = "5.1.0.0" +source = { registry = "https://pypi.nvidia.com/" } +dependencies = [ + { name = "isaacsim-robot" }, +] +wheels = [ + { url = "https://pypi.nvidia.com/isaacsim-test/isaacsim_test-5.1.0.0-cp311-none-manylinux_2_35_aarch64.whl", hash = "sha256:19419cb73a44734c3b45272f17471e17ffbb0b0eb91ff899f67e3ebf71c560f0" }, + { url = "https://pypi.nvidia.com/isaacsim-test/isaacsim_test-5.1.0.0-cp311-none-manylinux_2_35_x86_64.whl", hash = "sha256:b3a1b647d6bec16b649b5d6f4199b0dd8aa3d70fcc5cc9603fef8e6e9bf986dc" }, + { url = "https://pypi.nvidia.com/isaacsim-test/isaacsim_test-5.1.0.0-cp311-none-win_amd64.whl", hash = "sha256:43cc26aea985702c032b4fed7e0e24d035cc18206efb9fc2c29a2d62b29fe7bb" }, +] + +[[package]] +name = "isaacsim-utils" +version = "5.1.0.0" +source = { registry = "https://pypi.nvidia.com/" } +dependencies = [ + { name = "isaacsim-gui" }, +] +wheels = [ + { url = "https://pypi.nvidia.com/isaacsim-utils/isaacsim_utils-5.1.0.0-cp311-none-manylinux_2_35_aarch64.whl", hash = "sha256:90784431352e7acc4e18fdb3940ee6d9625d99ee638e975119db54f426027921" }, + { url = "https://pypi.nvidia.com/isaacsim-utils/isaacsim_utils-5.1.0.0-cp311-none-manylinux_2_35_x86_64.whl", hash = "sha256:becd39be3fc0d3b5abe332febf50c2af518aa99db5582344b53cba6076385116" }, + { url = "https://pypi.nvidia.com/isaacsim-utils/isaacsim_utils-5.1.0.0-cp311-none-win_amd64.whl", hash = "sha256:eba6954ccde8faded7a313f06180187b96e4aba1f8703332053e9b39964fdbf6" }, +] + +[[package]] +name = "isodate" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/7a/c0a56c7d56c7fa723988f122fa1f1ccf8c5c4ccc48efad0d214b49e5b1af/isodate-0.6.1.tar.gz", hash = "sha256:48c5881de7e8b0a0d648cb024c8062dc84e7b840ed81e864c7614fd3c127bde9", size = 28443 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/85/7882d311924cbcfc70b1890780763e36ff0b140c7e51c110fc59a532f087/isodate-0.6.1-py2.py3-none-any.whl", hash = "sha256:0751eece944162659049d35f4f549ed815792b38793f07cf73381c1c87cbed96", size = 41722 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, +] + +[[package]] +name = "jmespath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/2a/e867e8531cf3e36b41201936b7fa7ba7b5702dbef42922193f05c8976cd6/jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe", size = 25843 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256 }, +] + +[[package]] +name = "junitparser" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/12/ed/063362ed6c5e39273879ba3db91da13b00551c6277de6842e45ab55a1a22/junitparser-5.0.1.tar.gz", hash = "sha256:45d100ca35ce5e2596c1f251de5e0f9411827aa93edaba7ad2d8eef423eecdd0", size = 12051 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/32/15c6dd4267d530c38ca8b661c8322d201a6766087efd81ef81d3456e3cad/junitparser-5.0.1-py3-none-any.whl", hash = "sha256:019410471ac82c6b49c3cd500b930b3f39a5dae34e5ca5d5f719c4dcd9bb7e9a", size = 15014 }, +] + +[[package]] +name = "kiwisolver" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/5c/272a7dd49a1914f35cd8d6d9f386defa8b047f6fbd06badd6b77b3ba24e7/kiwisolver-1.4.4.tar.gz", hash = "sha256:d41997519fcba4a1e46eb4a2fe31bc12f0ff957b2b81bac28db24744f333e955", size = 97093 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/bf/7994af5c838c761b4998044dfabecce8c9f428479e32fe77edc7336dcfd2/kiwisolver-1.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e0ea21f66820452a3f5d1655f8704a60d66ba1191359b96541eaf457710a5fc6", size = 121811 }, + { url = "https://files.pythonhosted.org/packages/b7/9d/b9d5c0412d46defef863f365b8ab8817b660e1f05385c0ed670deab0aa49/kiwisolver-1.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bc9db8a3efb3e403e4ecc6cd9489ea2bac94244f80c78e27c31dcc00d2790ac2", size = 65479 }, + { url = "https://files.pythonhosted.org/packages/89/84/b63b6ada3b349605cf97e28b71bdf37dbf74207c5c56e0a03e583226edc0/kiwisolver-1.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d5b61785a9ce44e5a4b880272baa7cf6c8f48a5180c3e81c59553ba0cb0821ca", size = 63121 }, + { url = "https://files.pythonhosted.org/packages/70/85/2c6f6c2de0820c97d49ffa7e183ace21f02a683cd0d6fa98f58762e597f6/kiwisolver-1.4.4-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2dbb44c3f7e6c4d3487b31037b1bdbf424d97687c1747ce4ff2895795c9bf69", size = 1332632 }, + { url = "https://files.pythonhosted.org/packages/43/67/634a9c3854e4f908ff5ffd48ea51b1ca3e096ce79ffdd91ebdcd07d6d64b/kiwisolver-1.4.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6295ecd49304dcf3bfbfa45d9a081c96509e95f4b9d0eb7ee4ec0530c4a96514", size = 1425065 }, + { url = "https://files.pythonhosted.org/packages/ae/a1/5259f35063488465c433ddf70b000ba8eff024093849934b09d3bdc8fb2a/kiwisolver-1.4.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bd472dbe5e136f96a4b18f295d159d7f26fd399136f5b17b08c4e5f498cd494", size = 1539355 }, + { url = "https://files.pythonhosted.org/packages/92/be/d8b1ff785ef6ab899e6934e3e458580761beb561727ece19f83f96767de6/kiwisolver-1.4.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bf7d9fce9bcc4752ca4a1b80aabd38f6d19009ea5cbda0e0856983cf6d0023f5", size = 1468895 }, + { url = "https://files.pythonhosted.org/packages/ac/e6/823a136cefcf0592338827f54cb73642c2ea580acd8a7d5dbf8f32437848/kiwisolver-1.4.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78d6601aed50c74e0ef02f4204da1816147a6d3fbdc8b3872d263338a9052c51", size = 1424584 }, + { url = "https://files.pythonhosted.org/packages/78/df/13ab40e58fa093243f9732cfe2880fc84cee6963f75a889789a682bc1c50/kiwisolver-1.4.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:877272cf6b4b7e94c9614f9b10140e198d2186363728ed0f701c6eee1baec1da", size = 1846284 }, + { url = "https://files.pythonhosted.org/packages/48/36/b8605e1559c97522950658302fd7371affac055c554d45ba1c4665b29724/kiwisolver-1.4.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:db608a6757adabb32f1cfe6066e39b3706d8c3aa69bbc353a5b61edad36a5cb4", size = 1946287 }, + { url = "https://files.pythonhosted.org/packages/cb/ab/a94286c03f19851cfabeba28dde406ba29ca048d77d3342f7699268af649/kiwisolver-1.4.4-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:5853eb494c71e267912275e5586fe281444eb5e722de4e131cddf9d442615626", size = 1900425 }, + { url = "https://files.pythonhosted.org/packages/c5/52/3f96b6761fc70fb3e2fc8189fc1bc1ced3350321e6690189a1b3c6463bc8/kiwisolver-1.4.4-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:f0a1dbdb5ecbef0d34eb77e56fcb3e95bbd7e50835d9782a45df81cc46949750", size = 1852588 }, + { url = "https://files.pythonhosted.org/packages/69/6c/8597155d3755337c7e39a7aaf54a07de0ad2572b109d904aeb70b4ab6f36/kiwisolver-1.4.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:283dffbf061a4ec60391d51e6155e372a1f7a4f5b15d59c8505339454f8989e4", size = 1869687 }, + { url = "https://files.pythonhosted.org/packages/96/61/79804e00f7e8b5c54f5fce84740896a18142b5e85152c44d565c0d763f05/kiwisolver-1.4.4-cp311-cp311-win32.whl", hash = "sha256:d06adcfa62a4431d404c31216f0f8ac97397d799cd53800e9d3efc2fbb3cf14e", size = 45854 }, + { url = "https://files.pythonhosted.org/packages/63/33/a52b723c5e6f1a7b0d73d68761f05ba217519da3ec264ef32dbead9e68ec/kiwisolver-1.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:e7da3fec7408813a7cebc9e4ec55afed2d0fd65c4754bc376bf03498d4e92686", size = 55395 }, +] + +[[package]] +name = "lab-isaac" +version = "0.1.0" +source = { directory = "../../" } + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=1.17,<2" }, + { name = "pytest", specifier = ">=8.4,<9" }, + { name = "ruff", specifier = ">=0.12,<1" }, +] + +[[package]] +name = "lab-isaac-c3-runtime" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "isaaclab", extra = ["all", "isaacsim"] }, + { name = "lab-isaac" }, + { name = "rsl-rl-lib" }, + { name = "torch", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "torchvision", version = "0.22.0", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "python_version < '0'" }, + { name = "torchvision", version = "0.22.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, +] + +[package.metadata] +requires-dist = [ + { name = "isaaclab", extras = ["isaacsim", "all"], specifier = "==2.3.2.post1" }, + { name = "lab-isaac", directory = "../../" }, + { name = "rsl-rl-lib", specifier = "==3.0.1" }, + { name = "torch", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = "==2.7.0", index = "https://download.pytorch.org/whl/cu128" }, + { name = "torchvision", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = "==0.22.0", index = "https://download.pytorch.org/whl/cu128" }, +] + +[[package]] +name = "llvmlite" +version = "0.42.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/ff/ad02ffee7d519615726fc46c99a37e697f2b4b1fb7e5d3cd6fb465d4f49f/llvmlite-0.42.0.tar.gz", hash = "sha256:f92b09243c0cc3f457da8b983f67bd8e1295d0f5b3746c7a1861d7a99403854a", size = 156136 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/97/4aac09bdfc1bc35f8eb64e21ff5897224a788170e5e8cab3e62c9eb78efb/llvmlite-0.42.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ae511caed28beaf1252dbaf5f40e663f533b79ceb408c874c01754cafabb9cbf", size = 31064194 }, + { url = "https://files.pythonhosted.org/packages/ba/3a/286d01191e62ddbe645d4a3f1e0d96106a98d3fd7f82441d20ffe93ab669/llvmlite-0.42.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:81e674c2fe85576e6c4474e8c7e7aba7901ac0196e864fe7985492b737dbab65", size = 28793149 }, + { url = "https://files.pythonhosted.org/packages/e1/0b/4f9c7479137280bf868ee6f9bfe4540cd5f5d5522ecf72662e9ad78a153e/llvmlite-0.42.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb3975787f13eb97629052edb5017f6c170eebc1c14a0433e8089e5db43bcce6", size = 42790150 }, + { url = "https://files.pythonhosted.org/packages/a4/1f/300788b5eab99aec872ed2f3647386d7d7f7bbf4f99c91e9e023b404ff7f/llvmlite-0.42.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c5bece0cdf77f22379f19b1959ccd7aee518afa4afbd3656c6365865f84903f9", size = 43802727 }, + { url = "https://files.pythonhosted.org/packages/f3/bd/3b27a1c8bbbe01b053f5e0c9ca9a37dbc3e39282dfcf596d143ad389f156/llvmlite-0.42.0-cp311-cp311-win_amd64.whl", hash = "sha256:7e0c4c11c8c2aa9b0701f91b799cb9134a6a6de51444eff5a9087fc7c1384275", size = 28104178 }, +] + +[[package]] +name = "markdown" +version = "3.10.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/29/6f/da4c6aea59b3001f2e8c0ec7497475aadaf3b021c10cab5b2858f0f32b26/markdown-3.10.3.tar.gz", hash = "sha256:3589362618f743188b4d955b874402bc814f4f83f544dc207719f4baa7d9c45f", size = 372596 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/69/4a5af2bc115a9a33fefe51709749de8262be3f9ba063d1753a837cdbc49c/markdown-3.10.3-py3-none-any.whl", hash = "sha256:fa6c92a00a4a3c98b22728c64a935ae1928250ae65058a6ded814d2cc29a4cea", size = 110757 }, +] + +[[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 } +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 }, +] + +[[package]] +name = "markupsafe" +version = "2.1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/7c/59a3248f411813f8ccba92a55feaac4bf360d29e2ff05ee7d8e1ef2d7dbf/MarkupSafe-2.1.3.tar.gz", hash = "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad", size = 19132 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/09/c31503cb8150cf688c1534a7135cc39bb9092f8e0e6369ec73494d16ee0e/MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c", size = 17862 }, + { url = "https://files.pythonhosted.org/packages/c0/c7/171f5ac6b065e1425e8fabf4a4dfbeca76fd8070072c6a41bd5c07d90d8b/MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575", size = 13738 }, + { url = "https://files.pythonhosted.org/packages/a2/f7/9175ad1b8152092f7c3b78c513c1bdfe9287e0564447d1c2d3d1a2471540/MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee", size = 28891 }, + { url = "https://files.pythonhosted.org/packages/fe/21/2eff1de472ca6c99ec3993eab11308787b9879af9ca8bbceb4868cf4f2ca/MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2", size = 28096 }, + { url = "https://files.pythonhosted.org/packages/f4/a0/103f94793c3bf829a18d2415117334ece115aeca56f2df1c47fa02c6dbd6/MarkupSafe-2.1.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9", size = 27631 }, + { url = "https://files.pythonhosted.org/packages/43/70/f24470f33b2035b035ef0c0ffebf57006beb2272cf3df068fc5154e04ead/MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc", size = 33863 }, + { url = "https://files.pythonhosted.org/packages/32/d4/ce98c4ca713d91c4a17c1a184785cc00b9e9c25699d618956c2b9999500a/MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9", size = 32591 }, + { url = "https://files.pythonhosted.org/packages/bb/82/f88ccb3ca6204a4536cf7af5abdad7c3657adac06ab33699aa67279e0744/MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac", size = 33186 }, + { url = "https://files.pythonhosted.org/packages/44/53/93405d37bb04a10c43b1bdd6f548097478d494d7eadb4b364e3e1337f0cc/MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb", size = 16537 }, + { url = "https://files.pythonhosted.org/packages/be/bb/08b85bc194034efbf572e70c3951549c8eca0ada25363afc154386b5390a/MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686", size = 17089 }, +] + +[[package]] +name = "matplotlib" +version = "3.10.3" +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/26/91/d49359a21893183ed2a5b6c76bec40e0b1dcbf8ca148f864d134897cfc75/matplotlib-3.10.3.tar.gz", hash = "sha256:2f82d2c5bb7ae93aaaa4cd42aca65d76ce6376f83304fa3a630b569aca274df0", size = 34799811 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/bd/af9f655456f60fe1d575f54fb14704ee299b16e999704817a7645dfce6b0/matplotlib-3.10.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0ef061f74cd488586f552d0c336b2f078d43bc00dc473d2c3e7bfee2272f3fa8", size = 8178873 }, + { url = "https://files.pythonhosted.org/packages/c2/86/e1c86690610661cd716eda5f9d0b35eaf606ae6c9b6736687cfc8f2d0cd8/matplotlib-3.10.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d96985d14dc5f4a736bbea4b9de9afaa735f8a0fc2ca75be2fa9e96b2097369d", size = 8052205 }, + { url = "https://files.pythonhosted.org/packages/54/51/a9f8e49af3883dacddb2da1af5fca1f7468677f1188936452dd9aaaeb9ed/matplotlib-3.10.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7c5f0283da91e9522bdba4d6583ed9d5521566f63729ffb68334f86d0bb98049", size = 8465823 }, + { url = "https://files.pythonhosted.org/packages/e7/e3/c82963a3b86d6e6d5874cbeaa390166458a7f1961bab9feb14d3d1a10f02/matplotlib-3.10.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdfa07c0ec58035242bc8b2c8aae37037c9a886370eef6850703d7583e19964b", size = 8606464 }, + { url = "https://files.pythonhosted.org/packages/0e/34/24da1027e7fcdd9e82da3194c470143c551852757a4b473a09a012f5b945/matplotlib-3.10.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c0b9849a17bce080a16ebcb80a7b714b5677d0ec32161a2cc0a8e5a6030ae220", size = 9413103 }, + { url = "https://files.pythonhosted.org/packages/a6/da/948a017c3ea13fd4a97afad5fdebe2f5bbc4d28c0654510ce6fd6b06b7bd/matplotlib-3.10.3-cp311-cp311-win_amd64.whl", hash = "sha256:eef6ed6c03717083bc6d69c2d7ee8624205c29a8e6ea5a31cd3492ecdbaee1e1", size = 8065492 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, +] + +[[package]] +name = "ml-dtypes" +version = "0.5.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/4a/c27b42ed9b1c7d13d9ba8b6905dece787d6259152f2309338aed29b2447b/ml_dtypes-0.5.4.tar.gz", hash = "sha256:8ab06a50fb9bf9666dd0fe5dfb4676fa2b0ac0f31ecff72a6c3af8e22c063453", size = 692314 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/5e/712092cfe7e5eb667b8ad9ca7c54442f21ed7ca8979745f1000e24cf8737/ml_dtypes-0.5.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6c7ecb74c4bd71db68a6bea1edf8da8c34f3d9fe218f038814fd1d310ac76c90", size = 679734 }, + { url = "https://files.pythonhosted.org/packages/4f/cf/912146dfd4b5c0eea956836c01dcd2fce6c9c844b2691f5152aca196ce4f/ml_dtypes-0.5.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc11d7e8c44a65115d05e2ab9989d1e045125d7be8e05a071a48bc76eb6d6040", size = 5056165 }, + { url = "https://files.pythonhosted.org/packages/a9/80/19189ea605017473660e43762dc853d2797984b3c7bf30ce656099add30c/ml_dtypes-0.5.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:19b9a53598f21e453ea2fbda8aa783c20faff8e1eeb0d7ab899309a0053f1483", size = 5034975 }, + { url = "https://files.pythonhosted.org/packages/b4/24/70bd59276883fdd91600ca20040b41efd4902a923283c4d6edcb1de128d2/ml_dtypes-0.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:7c23c54a00ae43edf48d44066a7ec31e05fdc2eee0be2b8b50dd1903a1db94bb", size = 210742 }, + { url = "https://files.pythonhosted.org/packages/a0/c9/64230ef14e40aa3f1cb254ef623bf812735e6bec7772848d19131111ac0d/ml_dtypes-0.5.4-cp311-cp311-win_arm64.whl", hash = "sha256:557a31a390b7e9439056644cb80ed0735a6e3e3bb09d67fd5687e4b04238d1de", size = 160709 }, +] + +[[package]] +name = "moviepy" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "decorator" }, + { name = "imageio" }, + { name = "imageio-ffmpeg" }, + { name = "numpy" }, + { name = "pillow" }, + { name = "proglog" }, + { name = "python-dotenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/61/15f9476e270f64c78a834e7459ca045d669f869cec24eed26807b8cd479d/moviepy-2.2.1.tar.gz", hash = "sha256:c80cb56815ece94e5e3e2d361aa40070eeb30a09d23a24c4e684d03e16deacb1", size = 58431438 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/73/7d3b2010baa0b5eb1e4dfa9e4385e89b6716be76f2fa21a6c0fe34b68e5a/moviepy-2.2.1-py3-none-any.whl", hash = "sha256:6b56803fec2ac54b557404126ac1160e65448e03798fa282bd23e8fab3795060", size = 129871 }, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198 }, +] + +[[package]] +name = "msal" +version = "1.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a5/b0/006d1297baa1a6706525a3ae64d9575135128e260f9c0c74da5f5b8c584b/msal-1.27.0.tar.gz", hash = "sha256:3109503c038ba6b307152b0e8d34f98113f2e7a78986e28d0baf5b5303afda52", size = 129382 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/a3/46ebb1d55aa65c1983f0b96739c84957fbb5be3c55ce34ecbab69afd4242/msal-1.27.0-py2.py3-none-any.whl", hash = "sha256:572d07149b83e7343a85a3bcef8e581167b4ac76befcbbb6eef0c0e19643cdc0", size = 101545 }, +] + +[[package]] +name = "msal-extensions" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "msal" }, + { name = "portalocker" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/5e/2e23593c67df0b21ffb141c485ca0ae955569203d7ff5064040af968cb81/msal-extensions-1.0.0.tar.gz", hash = "sha256:c676aba56b0cce3783de1b5c5ecfe828db998167875126ca4b47dc6436451354", size = 18763 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/34/a8995d6f0fa626ff6b28dbd9c90f6c2a46bd484bc7ab343d078b0c6ff1a7/msal_extensions-1.0.0-py2.py3-none-any.whl", hash = "sha256:91e3db9620b822d0ed2b4d1850056a0f133cba04455e62f11612e40f5502f2ee", size = 19181 }, +] + +[[package]] +name = "multidict" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/be/504b89a5e9ca731cd47487e91c469064f8ae5af93b7259758dcfc2b9c848/multidict-6.1.0.tar.gz", hash = "sha256:22ae2ebf9b0c69d206c003e2f6a914ea33f0a932d4aa16f236afc049d9958f4a", size = 64002 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/13/df3505a46d0cd08428e4c8169a196131d1b0c4b515c3649829258843dde6/multidict-6.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3efe2c2cb5763f2f1b275ad2bf7a287d3f7ebbef35648a9726e3b69284a4f3d6", size = 48570 }, + { url = "https://files.pythonhosted.org/packages/f0/e1/a215908bfae1343cdb72f805366592bdd60487b4232d039c437fe8f5013d/multidict-6.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7053d3b0353a8b9de430a4f4b4268ac9a4fb3481af37dfe49825bf45ca24156", size = 29316 }, + { url = "https://files.pythonhosted.org/packages/70/0f/6dc70ddf5d442702ed74f298d69977f904960b82368532c88e854b79f72b/multidict-6.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:27e5fc84ccef8dfaabb09d82b7d179c7cf1a3fbc8a966f8274fcb4ab2eb4cadb", size = 29640 }, + { url = "https://files.pythonhosted.org/packages/d8/6d/9c87b73a13d1cdea30b321ef4b3824449866bd7f7127eceed066ccb9b9ff/multidict-6.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e2b90b43e696f25c62656389d32236e049568b39320e2735d51f08fd362761b", size = 131067 }, + { url = "https://files.pythonhosted.org/packages/cc/1e/1b34154fef373371fd6c65125b3d42ff5f56c7ccc6bfff91b9b3c60ae9e0/multidict-6.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d83a047959d38a7ff552ff94be767b7fd79b831ad1cd9920662db05fec24fe72", size = 138507 }, + { url = "https://files.pythonhosted.org/packages/fb/e0/0bc6b2bac6e461822b5f575eae85da6aae76d0e2a79b6665d6206b8e2e48/multidict-6.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d1a9dd711d0877a1ece3d2e4fea11a8e75741ca21954c919406b44e7cf971304", size = 133905 }, + { url = "https://files.pythonhosted.org/packages/ba/af/73d13b918071ff9b2205fcf773d316e0f8fefb4ec65354bbcf0b10908cc6/multidict-6.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec2abea24d98246b94913b76a125e855eb5c434f7c46546046372fe60f666351", size = 129004 }, + { url = "https://files.pythonhosted.org/packages/74/21/23960627b00ed39643302d81bcda44c9444ebcdc04ee5bedd0757513f259/multidict-6.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4867cafcbc6585e4b678876c489b9273b13e9fff9f6d6d66add5e15d11d926cb", size = 121308 }, + { url = "https://files.pythonhosted.org/packages/8b/5c/cf282263ffce4a596ed0bb2aa1a1dddfe1996d6a62d08842a8d4b33dca13/multidict-6.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5b48204e8d955c47c55b72779802b219a39acc3ee3d0116d5080c388970b76e3", size = 132608 }, + { url = "https://files.pythonhosted.org/packages/d7/3e/97e778c041c72063f42b290888daff008d3ab1427f5b09b714f5a8eff294/multidict-6.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d8fff389528cad1618fb4b26b95550327495462cd745d879a8c7c2115248e399", size = 127029 }, + { url = "https://files.pythonhosted.org/packages/47/ac/3efb7bfe2f3aefcf8d103e9a7162572f01936155ab2f7ebcc7c255a23212/multidict-6.1.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a7a9541cd308eed5e30318430a9c74d2132e9a8cb46b901326272d780bf2d423", size = 137594 }, + { url = "https://files.pythonhosted.org/packages/42/9b/6c6e9e8dc4f915fc90a9b7798c44a30773dea2995fdcb619870e705afe2b/multidict-6.1.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:da1758c76f50c39a2efd5e9859ce7d776317eb1dd34317c8152ac9251fc574a3", size = 134556 }, + { url = "https://files.pythonhosted.org/packages/1d/10/8e881743b26aaf718379a14ac58572a240e8293a1c9d68e1418fb11c0f90/multidict-6.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c943a53e9186688b45b323602298ab727d8865d8c9ee0b17f8d62d14b56f0753", size = 130993 }, + { url = "https://files.pythonhosted.org/packages/45/84/3eb91b4b557442802d058a7579e864b329968c8d0ea57d907e7023c677f2/multidict-6.1.0-cp311-cp311-win32.whl", hash = "sha256:90f8717cb649eea3504091e640a1b8568faad18bd4b9fcd692853a04475a4b80", size = 26405 }, + { url = "https://files.pythonhosted.org/packages/9f/0b/ad879847ecbf6d27e90a6eabb7eff6b62c129eefe617ea45eae7c1f0aead/multidict-6.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:82176036e65644a6cc5bd619f65f6f19781e8ec2e5330f51aa9ada7504cc1926", size = 28795 }, + { url = "https://files.pythonhosted.org/packages/99/b7/b9e70fde2c0f0c9af4cc5277782a89b66d35948ea3369ec9f598358c3ac5/multidict-6.1.0-py3-none-any.whl", hash = "sha256:48e171e52d1c4d33888e529b999e5900356b9ae588c2f09a52dcefb158b27506", size = 10051 }, +] + +[[package]] +name = "nest-asyncio" +version = "1.5.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/76/64c51c1cbe704ad79ef6ec82f232d1893b9365f2ff194111787dc91b004f/nest_asyncio-1.5.6.tar.gz", hash = "sha256:d267cc1ff794403f7df692964d1d2a3fa9418ffea2a3f6859a439ff482fef290", size = 7444 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/1a/6dd9ec31cfdb34cef8fea0055b593ee779a6f63c8e8038ad90d71b7f53c0/nest_asyncio-1.5.6-py3-none-any.whl", hash = "sha256:b9a953fb40dceaa587d109609098db21900182b16440652454a146cffb06e8b8", size = 5215 }, +] + +[[package]] +name = "networkx" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/1d/06475e1cd5264c0b870ea2cc6fdb3e37177c1e565c43f56ff17a10e3937f/networkx-3.4.2.tar.gz", hash = "sha256:307c3669428c5362aab27c8a1260aa8f47c4e91d3891f48be0141738d8d053e1", size = 2151368 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl", hash = "sha256:df5d4365b724cf81b8c6a7312509d0c22386097011ad1abe274afd5e9d3bbc5f", size = 1723263 }, +] + +[[package]] +name = "numba" +version = "0.59.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "llvmlite" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/84/468592513867604800592b58d106f5e7e6ef61de226b59c1e9313917fbbb/numba-0.59.1.tar.gz", hash = "sha256:76f69132b96028d2774ed20415e8c528a34e3299a40581bae178f0994a2f370b", size = 2652730 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/2d/085c21f3086eff0b830e5d03d084a1b4b10dfde0c65feeac6be8c361265c/numba-0.59.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:43727e7ad20b3ec23ee4fc642f5b61845c71f75dd2825b3c234390c6d8d64051", size = 2609202 }, + { url = "https://files.pythonhosted.org/packages/70/7d/0d1419479997319ca72ef735791c2ee50819f9c200adea96142ee7499fae/numba-0.59.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:411df625372c77959570050e861981e9d196cc1da9aa62c3d6a836b5cc338966", size = 2612123 }, + { url = "https://files.pythonhosted.org/packages/ab/97/d23ae27bb609e4ce804456b401bdde575a385a86786e7d1080e4d9b75c8d/numba-0.59.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2801003caa263d1e8497fb84829a7ecfb61738a95f62bc05693fcf1733e978e4", size = 3376706 }, + { url = "https://files.pythonhosted.org/packages/54/f2/7d1579037643c874fa73516ea84c07e8d30ea347fb1a88c03b198447655d/numba-0.59.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dd2842fac03be4e5324ebbbd4d2d0c8c0fc6e0df75c09477dd45b288a0777389", size = 3669279 }, + { url = "https://files.pythonhosted.org/packages/38/f0/ad848815b0adafcf5f238e728933950034355a8d59969772be1cd57606d8/numba-0.59.1-cp311-cp311-win_amd64.whl", hash = "sha256:0594b3dfb369fada1f8bb2e3045cd6c61a564c62e50cf1f86b4666bc721b3450", size = 2649028 }, +] + +[[package]] +name = "numpy" +version = "1.26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/55/b3/b13bce39ba82b7398c06d10446f5ffd5c07db39b09bd37370dc720c7951c/numpy-1.26.0.tar.gz", hash = "sha256:f93fc78fe8bf15afe2b8d6b6499f1c73953169fad1e9a8dd086cdff3190e7fdf", size = 15633455 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/2f/b42860931c1479714201495ffe47d74460a916ae426a21fc9b68c5e329aa/numpy-1.26.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:637c58b468a69869258b8ae26f4a4c6ff8abffd4a8334c830ffb63e0feefe99a", size = 20619338 }, + { url = "https://files.pythonhosted.org/packages/35/21/9e150d654da358beb29fe216f339dc17f2b2ac13fff2a89669401a910550/numpy-1.26.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:306545e234503a24fe9ae95ebf84d25cba1fdc27db971aa2d9f1ab6bba19a9dd", size = 13981953 }, + { url = "https://files.pythonhosted.org/packages/a9/84/baf694be765d68c73f0f8a9d52151c339aed5f2d64205824a6f29021170c/numpy-1.26.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c6adc33561bd1d46f81131d5352348350fc23df4d742bb246cdfca606ea1208", size = 14167328 }, + { url = "https://files.pythonhosted.org/packages/c4/36/161e2f8110f8c49e59f6107bd6da4257d30aff9f06373d0471811f73dcc5/numpy-1.26.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e062aa24638bb5018b7841977c360d2f5917268d125c833a686b7cbabbec496c", size = 18178118 }, + { url = "https://files.pythonhosted.org/packages/37/41/63975634a93da2a384d3c8084eba467242cab68daab0cd8f4fd470dcee26/numpy-1.26.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:546b7dd7e22f3c6861463bebb000646fa730e55df5ee4a0224408b5694cc6148", size = 18020808 }, + { url = "https://files.pythonhosted.org/packages/58/d2/cbc329aa908cb963bd849f14e24f59c002a488e9055fab2c68887a6b5f1c/numpy-1.26.0-cp311-cp311-win32.whl", hash = "sha256:c0b45c8b65b79337dee5134d038346d30e109e9e2e9d43464a2970e5c0e93229", size = 20750149 }, + { url = "https://files.pythonhosted.org/packages/93/fd/3f826c6d15d3bdcf65b8031e4835c52b7d9c45add25efa2314b53850e1a2/numpy-1.26.0-cp311-cp311-win_amd64.whl", hash = "sha256:eae430ecf5794cb7ae7fa3808740b015aa80747e5266153128ef055975a72b99", size = 15794407 }, +] + +[[package]] +name = "nvidia-cublas-cu12" +version = "12.8.3.14" +source = { registry = "https://pypi.nvidia.com/" } +wheels = [ + { url = "https://pypi.nvidia.com/nvidia-cublas-cu12/nvidia_cublas_cu12-12.8.3.14-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:3f0e05e7293598cf61933258b73e66a160c27d59c4422670bf0b79348c04be44" }, +] + +[[package]] +name = "nvidia-cuda-cupti-cu12" +version = "12.8.57" +source = { registry = "https://pypi.nvidia.com/" } +wheels = [ + { url = "https://pypi.nvidia.com/nvidia-cuda-cupti-cu12/nvidia_cuda_cupti_cu12-12.8.57-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e0b2eb847de260739bee4a3f66fac31378f4ff49538ff527a38a01a9a39f950" }, +] + +[[package]] +name = "nvidia-cuda-nvrtc-cu12" +version = "12.8.61" +source = { registry = "https://pypi.nvidia.com/" } +wheels = [ + { url = "https://pypi.nvidia.com/nvidia-cuda-nvrtc-cu12/nvidia_cuda_nvrtc_cu12-12.8.61-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:a0fa9c2a21583105550ebd871bd76e2037205d56f33f128e69f6d2a55e0af9ed" }, +] + +[[package]] +name = "nvidia-cuda-runtime-cu12" +version = "12.8.57" +source = { registry = "https://pypi.nvidia.com/" } +wheels = [ + { url = "https://pypi.nvidia.com/nvidia-cuda-runtime-cu12/nvidia_cuda_runtime_cu12-12.8.57-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:75342e28567340b7428ce79a5d6bb6ca5ff9d07b69e7ce00d2c7b4dc23eff0be" }, +] + +[[package]] +name = "nvidia-cudnn-cu12" +version = "9.7.1.26" +source = { registry = "https://pypi.nvidia.com/" } +dependencies = [ + { name = "nvidia-cublas-cu12" }, +] +wheels = [ + { url = "https://pypi.nvidia.com/nvidia-cudnn-cu12/nvidia_cudnn_cu12-9.7.1.26-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:6d011159a158f3cfc47bf851aea79e31bcff60d530b70ef70474c84cac484d07" }, +] + +[[package]] +name = "nvidia-cufft-cu12" +version = "11.3.3.41" +source = { registry = "https://pypi.nvidia.com/" } +dependencies = [ + { name = "nvidia-nvjitlink-cu12" }, +] +wheels = [ + { url = "https://pypi.nvidia.com/nvidia-cufft-cu12/nvidia_cufft_cu12-11.3.3.41-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:da650080ab79fcdf7a4b06aa1b460e99860646b176a43f6208099bdc17836b6a" }, +] + +[[package]] +name = "nvidia-cufile-cu12" +version = "1.13.0.11" +source = { registry = "https://pypi.nvidia.com/" } +wheels = [ + { url = "https://pypi.nvidia.com/nvidia-cufile-cu12/nvidia_cufile_cu12-1.13.0.11-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:483f434c541806936b98366f6d33caef5440572de8ddf38d453213729da3e7d4" }, +] + +[[package]] +name = "nvidia-curand-cu12" +version = "10.3.9.55" +source = { registry = "https://pypi.nvidia.com/" } +wheels = [ + { url = "https://pypi.nvidia.com/nvidia-curand-cu12/nvidia_curand_cu12-10.3.9.55-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:8387d974240c91f6a60b761b83d4b2f9b938b7e0b9617bae0f0dafe4f5c36b86" }, +] + +[[package]] +name = "nvidia-cusolver-cu12" +version = "11.7.2.55" +source = { registry = "https://pypi.nvidia.com/" } +dependencies = [ + { name = "nvidia-cublas-cu12" }, + { name = "nvidia-cusparse-cu12" }, + { name = "nvidia-nvjitlink-cu12" }, +] +wheels = [ + { url = "https://pypi.nvidia.com/nvidia-cusolver-cu12/nvidia_cusolver_cu12-11.7.2.55-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:4d1354102f1e922cee9db51920dba9e2559877cf6ff5ad03a00d853adafb191b" }, +] + +[[package]] +name = "nvidia-cusparse-cu12" +version = "12.5.7.53" +source = { registry = "https://pypi.nvidia.com/" } +dependencies = [ + { name = "nvidia-nvjitlink-cu12" }, +] +wheels = [ + { url = "https://pypi.nvidia.com/nvidia-cusparse-cu12/nvidia_cusparse_cu12-12.5.7.53-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3c1b61eb8c85257ea07e9354606b26397612627fdcd327bfd91ccf6155e7c86d" }, +] + +[[package]] +name = "nvidia-cusparselt-cu12" +version = "0.6.3" +source = { registry = "https://pypi.nvidia.com/" } +wheels = [ + { url = "https://pypi.nvidia.com/nvidia-cusparselt-cu12/nvidia_cusparselt_cu12-0.6.3-py3-none-manylinux2014_x86_64.whl", hash = "sha256:e5c8a26c36445dd2e6812f1177978a24e2d37cacce7e090f297a688d1ec44f46" }, +] + +[[package]] +name = "nvidia-nccl-cu12" +version = "2.26.2" +source = { registry = "https://pypi.nvidia.com/" } +wheels = [ + { url = "https://pypi.nvidia.com/nvidia-nccl-cu12/nvidia_nccl_cu12-2.26.2-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:694cf3879a206553cc9d7dbda76b13efaf610fdb70a50cba303de1b0d1530ac6" }, +] + +[[package]] +name = "nvidia-nvjitlink-cu12" +version = "12.8.61" +source = { registry = "https://pypi.nvidia.com/" } +wheels = [ + { url = "https://pypi.nvidia.com/nvidia-nvjitlink-cu12/nvidia_nvjitlink_cu12-12.8.61-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:45fd79f2ae20bd67e8bc411055939049873bfd8fac70ff13bd4865e0b9bdab17" }, +] + +[[package]] +name = "nvidia-nvtx-cu12" +version = "12.8.55" +source = { registry = "https://pypi.nvidia.com/" } +wheels = [ + { url = "https://pypi.nvidia.com/nvidia-nvtx-cu12/nvidia_nvtx_cu12-12.8.55-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2dd0780f1a55c21d8e06a743de5bd95653de630decfff40621dbde78cc307102" }, +] + +[[package]] +name = "oauthlib" +version = "3.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/fa/fbf4001037904031639e6bfbfc02badfc7e12f137a8afa254df6c4c8a670/oauthlib-3.2.2.tar.gz", hash = "sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918", size = 177352 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/80/cab10959dc1faead58dc8384a781dfbf93cb4d33d50988f7a69f1b7c9bbe/oauthlib-3.2.2-py3-none-any.whl", hash = "sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca", size = 151688 }, +] + +[[package]] +name = "omegaconf" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "antlr4-python3-runtime" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/3d/e4b57b8d9008c6ebe0d5eff901f91d5700cf7bdb8c8863df817463a7fd5e/omegaconf-2.3.1.tar.gz", hash = "sha256:e5e7de64aeebeddaf8e6d3f7a783b32ac2a01c0fbd9c878012caecb891a1f42a", size = 3298472 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/0e/152509871bf30df6fc38569f52a2db9b55dd41aae957adae50a053ac7778/omegaconf-2.3.1-py3-none-any.whl", hash = "sha256:3d701d14e9a8828f1edd28bb70b725908b34277cdd72cf7d6a83f94dadc6b6a0", size = 79502 }, +] + +[[package]] +name = "onnx" +version = "1.21.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ml-dtypes" }, + { name = "numpy" }, + { name = "protobuf" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/93/942d2a0f6a70538eea042ce0445c8aefd46559ad153469986f29a743c01c/onnx-1.21.0.tar.gz", hash = "sha256:4d8b67d0aaec5864c87633188b91cc520877477ec0254eda122bef8be43cd764", size = 12074608 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/48/32e383aa6bc40b72a9fd419937aaa647078190c9bfccdc97b316d2dee687/onnx-1.21.0-cp311-cp311-macosx_12_0_universal2.whl", hash = "sha256:2aca19949260875c14866fc77ea0bc37e4e809b24976108762843d328c92d3ce", size = 17968053 }, + { url = "https://files.pythonhosted.org/packages/e2/26/5726e8df7d36e96bb3c679912d1a86af42f393d77aa17d6b98a97d4289ce/onnx-1.21.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82aa6ab51144df07c58c4850cb78d4f1ae969d8c0bf657b28041796d49ba6974", size = 17534821 }, + { url = "https://files.pythonhosted.org/packages/d6/2b/021dcd2dd50c3c71b7959d7368526da384a295c162fb4863f36057973f78/onnx-1.21.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10c3185a232089335581fabb98fba4e86d3e8246b8140f2e406082438100ebda", size = 17616664 }, + { url = "https://files.pythonhosted.org/packages/12/00/afa32a46fa122a7ed42df1cfe8796922156a3725ba8fc581c4779c96e2fc/onnx-1.21.0-cp311-cp311-win32.whl", hash = "sha256:f53b3c15a3b539c16b99655c43c365622046d68c49b680c48eba4da2a4fb6f27", size = 16289035 }, + { url = "https://files.pythonhosted.org/packages/73/8d/483cc980a24d4c0131d0af06d0ff6a37fb08ae90a7848ece8cef645194f1/onnx-1.21.0-cp311-cp311-win_amd64.whl", hash = "sha256:5f78c411743db317a76e5d009f84f7e3d5380411a1567a868e82461a1e5c775d", size = 16443748 }, + { url = "https://files.pythonhosted.org/packages/38/78/9d06fd5aaaed1ec9cb8a3b70fbbf00c1bdc18db610771e96379f0ed58112/onnx-1.21.0-cp311-cp311-win_arm64.whl", hash = "sha256:ab6a488dabbb172eebc9f3b3e7ac68763f32b0c571626d4a5004608f866cc83d", size = 16406123 }, + { url = "https://files.pythonhosted.org/packages/7d/ae/cb644ec84c25e63575d9d8790fdcc5d1a11d67d3f62f872edb35fa38d158/onnx-1.21.0-cp312-abi3-macosx_12_0_universal2.whl", hash = "sha256:fc2635400fe39ff37ebc4e75342cc54450eadadf39c540ff132c319bf4960095", size = 17965930 }, + { url = "https://files.pythonhosted.org/packages/6f/b6/eeb5903586645ef8a49b4b7892580438741acc3df91d7a5bd0f3a59ea9cb/onnx-1.21.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9003d5206c01fa2ff4b46311566865d8e493e1a6998d4009ec6de39843f1b59b", size = 17531344 }, + { url = "https://files.pythonhosted.org/packages/a7/00/4823f06357892d1e60d6f34e7299d2ba4ed2108c487cc394f7ce85a3ff14/onnx-1.21.0-cp312-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9261bd580fb8548c9c37b3c6750387eb8f21ea43c63880d37b2c622e1684285", size = 17613697 }, + { url = "https://files.pythonhosted.org/packages/23/1d/391f3c567ae068c8ac4f1d1316bae97c9eb45e702f05975fe0e17ad441f0/onnx-1.21.0-cp312-abi3-win32.whl", hash = "sha256:9ea4e824964082811938a9250451d89c4ec474fe42dd36c038bfa5df31993d1e", size = 16287200 }, + { url = "https://files.pythonhosted.org/packages/9c/a6/5eefbe5b40ea96de95a766bd2e0e751f35bdea2d4b951991ec9afaa69531/onnx-1.21.0-cp312-abi3-win_amd64.whl", hash = "sha256:458d91948ad9a7729a347550553b49ab6939f9af2cddf334e2116e45467dc61f", size = 16441045 }, + { url = "https://files.pythonhosted.org/packages/63/c4/0ed8dc037a39113d2a4d66e0005e07751c299c46b993f1ad5c2c35664c20/onnx-1.21.0-cp312-abi3-win_arm64.whl", hash = "sha256:ca14bc4842fccc3187eb538f07eabeb25a779b39388b006db4356c07403a7bbb", size = 16403134 }, +] + +[[package]] +name = "onnx-ir" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ml-dtypes" }, + { name = "numpy" }, + { name = "onnx" }, + { name = "sympy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d6/c2/61194cec0dbc5622273c0ebd592d37cc1dca0d7f1a744f02edd45ac905a3/onnx_ir-1.0.0.tar.gz", hash = "sha256:9e261f25fde8da9612ae5cb43b3b374d5ff469c04af0363cad588b2bb000b812", size = 163121 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/cd/6d1637172eb59c7b18ac90ed089d1f599a11fe0e63b4db2d017f3bb38a32/onnx_ir-1.0.0-py3-none-any.whl", hash = "sha256:e578f0d608d3062866b48223616eb2d10a6d6d01f8b8faac596129034f483cc7", size = 185849 }, +] + +[[package]] +name = "onnxscript" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ml-dtypes" }, + { name = "numpy" }, + { name = "onnx" }, + { name = "onnx-ir" }, + { name = "packaging" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/3a/4d79bce3f460e0df7fed54a92ce80827f25da66511da368bb00783ad8d20/onnxscript-0.7.1.tar.gz", hash = "sha256:309fb86484b11fa4ded90dba580e0d63f1a0827588e521cecaf2eeddb46d6e86", size = 618160 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/bd/a0c8e737b6afda10e42a597787d53d5b66e00268df6f59184701eeae37d9/onnxscript-0.7.1-py3-none-any.whl", hash = "sha256:544763b7fdef49940cdd9412ff5135cbae96d59ac6bc1921457f21280f40f4b7", size = 721970 }, +] + +[[package]] +name = "opencv-python-headless" +version = "4.11.0.86" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/2f/5b2b3ba52c864848885ba988f24b7f105052f68da9ab0e693cc7c25b0b30/opencv-python-headless-4.11.0.86.tar.gz", hash = "sha256:996eb282ca4b43ec6a3972414de0e2331f5d9cda2b41091a49739c19fb843798", size = 95177929 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/53/2c50afa0b1e05ecdb4603818e85f7d174e683d874ef63a6abe3ac92220c8/opencv_python_headless-4.11.0.86-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:48128188ade4a7e517237c8e1e11a9cdf5c282761473383e77beb875bb1e61ca", size = 37326460 }, + { url = "https://files.pythonhosted.org/packages/3b/43/68555327df94bb9b59a1fd645f63fafb0762515344d2046698762fc19d58/opencv_python_headless-4.11.0.86-cp37-abi3-macosx_13_0_x86_64.whl", hash = "sha256:a66c1b286a9de872c343ee7c3553b084244299714ebb50fbdcd76f07ebbe6c81", size = 56723330 }, + { url = "https://files.pythonhosted.org/packages/45/be/1438ce43ebe65317344a87e4b150865c5585f4c0db880a34cdae5ac46881/opencv_python_headless-4.11.0.86-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6efabcaa9df731f29e5ea9051776715b1bdd1845d7c9530065c7951d2a2899eb", size = 29487060 }, + { url = "https://files.pythonhosted.org/packages/dd/5c/c139a7876099916879609372bfa513b7f1257f7f1a908b0bdc1c2328241b/opencv_python_headless-4.11.0.86-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e0a27c19dd1f40ddff94976cfe43066fbbe9dfbb2ec1907d66c19caef42a57b", size = 49969856 }, + { url = "https://files.pythonhosted.org/packages/95/dd/ed1191c9dc91abcc9f752b499b7928aacabf10567bb2c2535944d848af18/opencv_python_headless-4.11.0.86-cp37-abi3-win32.whl", hash = "sha256:f447d8acbb0b6f2808da71fddd29c1cdd448d2bc98f72d9bb78a7a898fc9621b", size = 29324425 }, + { url = "https://files.pythonhosted.org/packages/86/8a/69176a64335aed183529207ba8bc3d329c2999d852b4f3818027203f50e6/opencv_python_headless-4.11.0.86-cp37-abi3-win_amd64.whl", hash = "sha256:6c304df9caa7a6a5710b91709dd4786bf20a74d57672b3c31f7033cc638174ca", size = 39402386 }, +] + +[[package]] +name = "orjson" +version = "3.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/f3/742fb1f62b825f2c010697eaf4e828004bc2a81e7e806666989c132c7c42/orjson-3.12.0.tar.gz", hash = "sha256:d14203fb1aae2ad9b3d52f8a0e82aeb10197ef1c9bc61da7f358bd70b00123d5", size = 4142915 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/1a/a7075a8e8b0d3f5097d17ac3099017104b6b7b42012041147995d5b2da05/orjson-3.12.0-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a94f0f0c6fcbb2b5bd9734c57a489c7584a732bbdf04a39e8c83b861e9d03e92", size = 223409 }, + { url = "https://files.pythonhosted.org/packages/05/34/c2eb3b2900e5597db7841a4c6416ac2d90081bd956b02d4dd1833fa2b96b/orjson-3.12.0-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:a696529ec96a90d9a5f9570207efe403c8b08f8e4aa2783ee3403511e2fdfa10", size = 124015 }, + { url = "https://files.pythonhosted.org/packages/1c/df/b49081766a75b6a37b3d33bdc0a39e492abab8441dd25e3e1998e7b83fcb/orjson-3.12.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:e4ac5059baab4b3acbd99485de019ff8cda0fdf34b61fa74f7197a53db78bfe8", size = 113471 }, + { url = "https://files.pythonhosted.org/packages/48/d4/58ea28eeef95c2a27358ed927380a621162cf20bd740bbccf9c3f09a200a/orjson-3.12.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:8e29957429c35bbb5a185a119c523aa2428b7bbf1a293724c7b9375ed8f892a3", size = 129998 }, + { url = "https://files.pythonhosted.org/packages/e2/f4/1e82aa2efc9916422d804697876ce433c907a1abd7c7e5c6d3d48565e5f9/orjson-3.12.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dce0166feb0a737ab84f598c9a338cbc0b764a036617aa686194f53c7eba0c3e", size = 130891 }, + { url = "https://files.pythonhosted.org/packages/5b/e1/15169e9d22b59a406264f99d6db387c0b0b12b6357a8a0169917c2a713eb/orjson-3.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9caf3d09f47c3c70c4451ada20ef9bc4a4cdffa26f49862cf0a253b329aae2d5", size = 131285 }, + { url = "https://files.pythonhosted.org/packages/a4/3a/763dbd426290d044ec3e615a05e70adb6d8b6f95bf17dc355c0081a5e8b6/orjson-3.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b9dca132b1fda5565088e65a6b6e742285e0aeceb6fae549fa8863e16c7d3998", size = 135707 }, + { url = "https://files.pythonhosted.org/packages/04/d1/3b2038ed168d22e14182ed715d6963f9c073a83a2ba43cfe918a4fc43c64/orjson-3.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a791f793b287bbc135b8e87c34e35c8bfc693e2a8a620fab1ae682b925f9a32e", size = 127669 }, + { url = "https://files.pythonhosted.org/packages/88/ae/b84b3d3e65f5629ada0edcb1d2bccc55d7c5f89d8b981537ecdc3d6f31ec/orjson-3.12.0-cp311-cp311-win32.whl", hash = "sha256:31ed278a36304390adc3eec5d7f6fd593a7c3e99e5a06cd07866396c4b1b4710", size = 128043 }, + { url = "https://files.pythonhosted.org/packages/35/24/2ed0e6f51ea3d0af45d807233a851175af75bec83ef5fd0d6a2601904ec0/orjson-3.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:fb2539159dfe8d371914f354360fa50e4a577cc89222a3828b9650a5e5040252", size = 122084 }, + { url = "https://files.pythonhosted.org/packages/21/dd/95d25fcfbc9471799ef6bb01c552d64ee5cde93ee40ba2f423dd3442c708/orjson-3.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:61318b6de893c7a9d9f3e5ecbadccbfc26a7eb417ccc7bbf0771de3b4d72f868", size = 127035 }, +] + +[[package]] +name = "osqp" +version = "0.6.7.post3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "qdldl" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/35/45d4d1832b31d207f83e0f9734d041be125fb4f0dff49413674bd1b08032/osqp-0.6.7.post3.tar.gz", hash = "sha256:b0c5e0a721f21c9724097a4fd50108304d296468d124e16f34ac67046f7020e1", size = 229274 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/dd/123079f0ad8409d3be9074344a3d45073ce928f701890f010ab506ffee9f/osqp-0.6.7.post3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b1a1dcd869fd6ac501e06262c21483a3691b6281e4f3f65af6951330958b89ca", size = 251844 }, + { url = "https://files.pythonhosted.org/packages/cd/6d/0d17e8fa61809c125f97685d86e6cd6f7b1e745e01b8d3f96d783c8de41b/osqp-0.6.7.post3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:46b93d1110dc0ad311f6691c4df9ee41cbbde5ffc0d8c8d520d4555bf5d8765b", size = 237567 }, + { url = "https://files.pythonhosted.org/packages/4f/74/d748a9f42426fa48ab0139d0738988296a3c599a6b3c78395258d02e436b/osqp-0.6.7.post3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5209104d6fe3ace4fdbf9ace08caa2cba9de1e7ccd5f56279a346c235917138b", size = 293855 }, + { url = "https://files.pythonhosted.org/packages/55/72/8746c4bc488a31641091ccc50e71f92e0a4211e2ef882e00904940531962/osqp-0.6.7.post3-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdfefa07740e9fb1c574cdc836e5afe2600b73c0c12089955d4ae6587c55f0eb", size = 298312 }, + { url = "https://files.pythonhosted.org/packages/f8/7b/ec42030f389c1b2a7e5517d4ba4a169f1d8fb6f4beb92c5b457e0cc284e4/osqp-0.6.7.post3-cp311-cp311-win_amd64.whl", hash = "sha256:c48c91dfba02ce11e8b8f5d401ec5b67a316782bfdf4f53ca753e49907f7387f", size = 293043 }, +] + +[[package]] +name = "packaging" +version = "23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/d5/aca8ff6f49aa5565df1c826e7bf5e85a6df852ee063600c1efa5b932968c/packaging-23.0.tar.gz", hash = "sha256:b6ad297f8907de0fa2fe1ccbd26fdaf387f5f47c7275fedf8cce89f99446cf97", size = 126241 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/35/a31aed2993e398f6b09a790a181a7927eb14610ee8bbf02dc14d31677f1c/packaging-23.0-py3-none-any.whl", hash = "sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2", size = 42678 }, +] + +[[package]] +name = "pandas" +version = "3.0.5" +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/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/ef/f1fd7431d635bf20015489bf0bd69c17fff1018de773540f651455a3916b/pandas-3.0.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2946e77e4a53cd248cbde631a12f0e51c8324ce354c3eba4d20147c1ad6f4282", size = 10397178 }, + { url = "https://files.pythonhosted.org/packages/31/b4/0eafac990a431561187694126de01f9b12559549b4d86360c0c4bd870fde/pandas-3.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71ecc8fb7ed1a7aa4392316b5309a6347e8e7f832f38fd897846b3a1457a9298", size = 9990736 }, + { url = "https://files.pythonhosted.org/packages/de/21/359880af3ea9b7cb23bea5b51e8e70ef3866c03be09da9a2787e18e330a8/pandas-3.0.5-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b173f5951ff6b8b0ec7675e20dff3c97b7e7a57dfcce387c2d7c5afe87cb7899", size = 10814438 }, + { url = "https://files.pythonhosted.org/packages/d1/50/d6cc4d7e508bbccf5d6027314a8312bc7ac73d0ec7f195f53838daafab40/pandas-3.0.5-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2c0cf1dd9b55a22d105fc46c1b489af3bd42264fcba7c66297bf47a9a1d9c78a", size = 11323634 }, + { url = "https://files.pythonhosted.org/packages/70/2b/d5f0a8c90dd0ae04e64ba53b871afb796ec026b615086d382ddc2ade729b/pandas-3.0.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0fac0010c75e4efb6b99e249c183a8993ce0dc95c240f9b120a5e67c727b7928", size = 11850860 }, + { url = "https://files.pythonhosted.org/packages/5c/30/183aec2e19adf778a98d29b5729a0a68f4cc4ebf9b9c3b70d0297355bcb1/pandas-3.0.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:08d24fe11a17dc33bd6e937dc9c665f9cba08fbdc9f657f405713515febe300d", size = 12411100 }, + { url = "https://files.pythonhosted.org/packages/fa/9a/31f4983f191af51ab2a8f2d0c7b33dff3a84da26533f982fff02c2f9e28b/pandas-3.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:b1261758dfb6cf12c3cff8300e21cefad30e7ec709abb4c24ac7318e6a52462a", size = 9968804 }, + { url = "https://files.pythonhosted.org/packages/49/97/7886c89a39045c69ad82cbceaf3343810480c8ef49a216319ce8183860a6/pandas-3.0.5-cp311-cp311-win_arm64.whl", hash = "sha256:679f4e85b30ddb1515458ab1e788d3e260eae369b1f78da7a3aa4cac8ebf4a2a", size = 9205447 }, +] + +[[package]] +name = "pillow" +version = "11.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/26/77f8ed17ca4ffd60e1dcd220a6ec6d71210ba398cfa33a13a1cd614c5613/pillow-11.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722", size = 5316531 }, + { url = "https://files.pythonhosted.org/packages/cb/39/ee475903197ce709322a17a866892efb560f57900d9af2e55f86db51b0a5/pillow-11.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288", size = 4686560 }, + { url = "https://files.pythonhosted.org/packages/d5/90/442068a160fd179938ba55ec8c97050a612426fae5ec0a764e345839f76d/pillow-11.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1aa4de119a0ecac0a34a9c8bde33f34022e2e8f99104e47a3ca392fd60e37d", size = 5870978 }, + { url = "https://files.pythonhosted.org/packages/13/92/dcdd147ab02daf405387f0218dcf792dc6dd5b14d2573d40b4caeef01059/pillow-11.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:91da1d88226663594e3f6b4b8c3c8d85bd504117d043740a8e0ec449087cc494", size = 7641168 }, + { url = "https://files.pythonhosted.org/packages/6e/db/839d6ba7fd38b51af641aa904e2960e7a5644d60ec754c046b7d2aee00e5/pillow-11.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:643f189248837533073c405ec2f0bb250ba54598cf80e8c1e043381a60632f58", size = 5973053 }, + { url = "https://files.pythonhosted.org/packages/f2/2f/d7675ecae6c43e9f12aa8d58b6012683b20b6edfbdac7abcb4e6af7a3784/pillow-11.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:106064daa23a745510dabce1d84f29137a37224831d88eb4ce94bb187b1d7e5f", size = 6640273 }, + { url = "https://files.pythonhosted.org/packages/45/ad/931694675ede172e15b2ff03c8144a0ddaea1d87adb72bb07655eaffb654/pillow-11.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd8ff254faf15591e724dc7c4ddb6bf4793efcbe13802a4ae3e863cd300b493e", size = 6082043 }, + { url = "https://files.pythonhosted.org/packages/3a/04/ba8f2b11fc80d2dd462d7abec16351b45ec99cbbaea4387648a44190351a/pillow-11.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:932c754c2d51ad2b2271fd01c3d121daaa35e27efae2a616f77bf164bc0b3e94", size = 6715516 }, + { url = "https://files.pythonhosted.org/packages/48/59/8cd06d7f3944cc7d892e8533c56b0acb68399f640786313275faec1e3b6f/pillow-11.3.0-cp311-cp311-win32.whl", hash = "sha256:b4b8f3efc8d530a1544e5962bd6b403d5f7fe8b9e08227c6b255f98ad82b4ba0", size = 6274768 }, + { url = "https://files.pythonhosted.org/packages/f1/cc/29c0f5d64ab8eae20f3232da8f8571660aa0ab4b8f1331da5c2f5f9a938e/pillow-11.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:1a992e86b0dd7aeb1f053cd506508c0999d710a8f07b4c791c63843fc6a807ac", size = 6986055 }, + { url = "https://files.pythonhosted.org/packages/c6/df/90bd886fabd544c25addd63e5ca6932c86f2b701d5da6c7839387a076b4a/pillow-11.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:30807c931ff7c095620fe04448e2c2fc673fcbb1ffe2a7da3fb39613489b1ddd", size = 2423079 }, + { url = "https://files.pythonhosted.org/packages/9e/e3/6fa84033758276fb31da12e5fb66ad747ae83b93c67af17f8c6ff4cc8f34/pillow-11.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6", size = 5270566 }, + { url = "https://files.pythonhosted.org/packages/5b/ee/e8d2e1ab4892970b561e1ba96cbd59c0d28cf66737fc44abb2aec3795a4e/pillow-11.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438", size = 4654618 }, + { url = "https://files.pythonhosted.org/packages/f2/6d/17f80f4e1f0761f02160fc433abd4109fa1548dcfdca46cfdadaf9efa565/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe27fb049cdcca11f11a7bfda64043c37b30e6b91f10cb5bab275806c32f6ab3", size = 4874248 }, + { url = "https://files.pythonhosted.org/packages/de/5f/c22340acd61cef960130585bbe2120e2fd8434c214802f07e8c03596b17e/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:465b9e8844e3c3519a983d58b80be3f668e2a7a5db97f2784e7079fbc9f9822c", size = 6583963 }, + { url = "https://files.pythonhosted.org/packages/31/5e/03966aedfbfcbb4d5f8aa042452d3361f325b963ebbadddac05b122e47dd/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5418b53c0d59b3824d05e029669efa023bbef0f3e92e75ec8428f3799487f361", size = 4957170 }, + { url = "https://files.pythonhosted.org/packages/cc/2d/e082982aacc927fc2cab48e1e731bdb1643a1406acace8bed0900a61464e/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:504b6f59505f08ae014f724b6207ff6222662aab5cc9542577fb084ed0676ac7", size = 5581505 }, + { url = "https://files.pythonhosted.org/packages/34/e7/ae39f538fd6844e982063c3a5e4598b8ced43b9633baa3a85ef33af8c05c/pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8", size = 6984598 }, +] + +[[package]] +name = "pint" +version = "0.20.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/d1/56923579866231eb4e61f86f4728ccd84fc2add7ad111ee25e4b64df47ec/Pint-0.20.1.tar.gz", hash = "sha256:387cf04078dc7dfe4a708033baad54ab61d82ab06c4ee3d4922b1e45d5626067", size = 316180 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/1b/97d8fd443d89e8d39b3aaa95aa70a184f752b68d2cc803f7fedab8dfd81f/Pint-0.20.1-py3-none-any.whl", hash = "sha256:68afe65665542ee3ec99f69f043b1d39bfe7c6d61b786940157138fd08b838fb", size = 269457 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, +] + +[[package]] +name = "portalocker" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/f8/969e6f280201b40b31bcb62843c619f343dcc351dff83a5891530c9dd60e/portalocker-2.7.0.tar.gz", hash = "sha256:032e81d534a88ec1736d03f780ba073f047a06c478b06e2937486f334e955c51", size = 20183 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/df/d4f711d168524f5aebd7fb30969eaa31e3048cf8979688cde3b08f6e5eb8/portalocker-2.7.0-py2.py3-none-any.whl", hash = "sha256:a07c5b4f3985c3cf4798369631fb7011adb498e2a46d8440efc75a8f29a0f983", size = 15502 }, +] + +[[package]] +name = "prettytable" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/88/ef38a6e4bc375600d3031e405a8d3b3dc4a154fccffd21d5d06e66c96230/prettytable-3.3.0.tar.gz", hash = "sha256:118eb54fd2794049b810893653b20952349df6d3bc1764e7facd8a18064fa9b0", size = 54305 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/ab/64371af206988d7b15c8112c9c277b8eb4618397c01471e52b902a17f59c/prettytable-3.3.0-py3-none-any.whl", hash = "sha256:d1c34d72ea2c0ffd6ce5958e71c428eb21a3d40bf3133afe319b24aeed5af407", size = 26815 }, +] + +[[package]] +name = "proglog" +version = "0.1.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/af/c108866c452eda1132f3d6b3cb6be2ae8430c97e9309f38ca9dbd430af37/proglog-0.1.12.tar.gz", hash = "sha256:361ee074721c277b89b75c061336cb8c5f287c92b043efa562ccf7866cda931c", size = 8794 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/1b/f7ea6cde25621cd9236541c66ff018f4268012a534ec31032bcb187dc5e7/proglog-0.1.12-py3-none-any.whl", hash = "sha256:ccaafce51e80a81c65dc907a460c07ccb8ec1f78dc660cfd8f9ec3a22f01b84c", size = 6337 }, +] + +[[package]] +name = "propcache" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/c8/2a13f78d82211490855b2fb303b6721348d0787fdd9a12ac46d99d3acde1/propcache-0.2.1.tar.gz", hash = "sha256:3f77ce728b19cb537714499928fe800c3dda29e8d9428778fc7c186da4c09a64", size = 41735 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/0f/2913b6791ebefb2b25b4efd4bb2299c985e09786b9f5b19184a88e5778dd/propcache-0.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ffc3cca89bb438fb9c95c13fc874012f7b9466b89328c3c8b1aa93cdcfadd16", size = 79297 }, + { url = "https://files.pythonhosted.org/packages/cf/73/af2053aeccd40b05d6e19058419ac77674daecdd32478088b79375b9ab54/propcache-0.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f174bbd484294ed9fdf09437f889f95807e5f229d5d93588d34e92106fbf6717", size = 45611 }, + { url = "https://files.pythonhosted.org/packages/3c/09/8386115ba7775ea3b9537730e8cf718d83bbf95bffe30757ccf37ec4e5da/propcache-0.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:70693319e0b8fd35dd863e3e29513875eb15c51945bf32519ef52927ca883bc3", size = 45146 }, + { url = "https://files.pythonhosted.org/packages/03/7a/793aa12f0537b2e520bf09f4c6833706b63170a211ad042ca71cbf79d9cb/propcache-0.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b480c6a4e1138e1aa137c0079b9b6305ec6dcc1098a8ca5196283e8a49df95a9", size = 232136 }, + { url = "https://files.pythonhosted.org/packages/f1/38/b921b3168d72111769f648314100558c2ea1d52eb3d1ba7ea5c4aa6f9848/propcache-0.2.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d27b84d5880f6d8aa9ae3edb253c59d9f6642ffbb2c889b78b60361eed449787", size = 239706 }, + { url = "https://files.pythonhosted.org/packages/14/29/4636f500c69b5edea7786db3c34eb6166f3384b905665ce312a6e42c720c/propcache-0.2.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:857112b22acd417c40fa4595db2fe28ab900c8c5fe4670c7989b1c0230955465", size = 238531 }, + { url = "https://files.pythonhosted.org/packages/85/14/01fe53580a8e1734ebb704a3482b7829a0ef4ea68d356141cf0994d9659b/propcache-0.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf6c4150f8c0e32d241436526f3c3f9cbd34429492abddbada2ffcff506c51af", size = 231063 }, + { url = "https://files.pythonhosted.org/packages/33/5c/1d961299f3c3b8438301ccfbff0143b69afcc30c05fa28673cface692305/propcache-0.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66d4cfda1d8ed687daa4bc0274fcfd5267873db9a5bc0418c2da19273040eeb7", size = 220134 }, + { url = "https://files.pythonhosted.org/packages/00/d0/ed735e76db279ba67a7d3b45ba4c654e7b02bc2f8050671ec365d8665e21/propcache-0.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2f992c07c0fca81655066705beae35fc95a2fa7366467366db627d9f2ee097f", size = 220009 }, + { url = "https://files.pythonhosted.org/packages/75/90/ee8fab7304ad6533872fee982cfff5a53b63d095d78140827d93de22e2d4/propcache-0.2.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:4a571d97dbe66ef38e472703067021b1467025ec85707d57e78711c085984e54", size = 212199 }, + { url = "https://files.pythonhosted.org/packages/eb/ec/977ffaf1664f82e90737275873461695d4c9407d52abc2f3c3e24716da13/propcache-0.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:bb6178c241278d5fe853b3de743087be7f5f4c6f7d6d22a3b524d323eecec505", size = 214827 }, + { url = "https://files.pythonhosted.org/packages/57/48/031fb87ab6081764054821a71b71942161619549396224cbb242922525e8/propcache-0.2.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ad1af54a62ffe39cf34db1aa6ed1a1873bd548f6401db39d8e7cd060b9211f82", size = 228009 }, + { url = "https://files.pythonhosted.org/packages/1a/06/ef1390f2524850838f2390421b23a8b298f6ce3396a7cc6d39dedd4047b0/propcache-0.2.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e7048abd75fe40712005bcfc06bb44b9dfcd8e101dda2ecf2f5aa46115ad07ca", size = 231638 }, + { url = "https://files.pythonhosted.org/packages/38/2a/101e6386d5a93358395da1d41642b79c1ee0f3b12e31727932b069282b1d/propcache-0.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:160291c60081f23ee43d44b08a7e5fb76681221a8e10b3139618c5a9a291b84e", size = 222788 }, + { url = "https://files.pythonhosted.org/packages/db/81/786f687951d0979007e05ad9346cd357e50e3d0b0f1a1d6074df334b1bbb/propcache-0.2.1-cp311-cp311-win32.whl", hash = "sha256:819ce3b883b7576ca28da3861c7e1a88afd08cc8c96908e08a3f4dd64a228034", size = 40170 }, + { url = "https://files.pythonhosted.org/packages/cf/59/7cc7037b295d5772eceb426358bb1b86e6cab4616d971bd74275395d100d/propcache-0.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:edc9fc7051e3350643ad929df55c451899bb9ae6d24998a949d2e4c87fb596d3", size = 44404 }, + { url = "https://files.pythonhosted.org/packages/41/b6/c5319caea262f4821995dca2107483b94a3345d4607ad797c76cb9c36bcc/propcache-0.2.1-py3-none-any.whl", hash = "sha256:52277518d6aae65536e9cea52d4e7fd2f7a66f4aa2d30ed3f2fcea620ace3c54", size = 11818 }, +] + +[[package]] +name = "protobuf" +version = "7.35.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226 }, + { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847 }, + { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030 }, + { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130 }, + { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945 }, + { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996 }, + { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659 }, +] + +[[package]] +name = "psutil" +version = "5.9.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/c7/6dc0a455d111f68ee43f27793971cf03fe29b6ef972042549db29eec39a2/psutil-5.9.8.tar.gz", hash = "sha256:6be126e3225486dff286a8fb9a06246a5253f4c7c53b475ea5f5ac934e64194c", size = 503247 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/e3/07ae864a636d70a8a6f58da27cb1179192f1140d5d1da10886ade9405797/psutil-5.9.8-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:aee678c8720623dc456fa20659af736241f575d79429a0e5e9cf88ae0605cc81", size = 248702 }, + { url = "https://files.pythonhosted.org/packages/b3/bd/28c5f553667116b2598b9cc55908ec435cb7f77a34f2bff3e3ca765b0f78/psutil-5.9.8-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cb6403ce6d8e047495a701dc7c5bd788add903f8986d523e3e20b98b733e421", size = 285242 }, + { url = "https://files.pythonhosted.org/packages/c5/4f/0e22aaa246f96d6ac87fe5ebb9c5a693fbe8877f537a1022527c47ca43c5/psutil-5.9.8-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d06016f7f8625a1825ba3732081d77c94589dca78b7a3fc072194851e88461a4", size = 288191 }, + { url = "https://files.pythonhosted.org/packages/6e/f5/2aa3a4acdc1e5940b59d421742356f133185667dd190b166dbcfcf5d7b43/psutil-5.9.8-cp37-abi3-win32.whl", hash = "sha256:bc56c2a1b0d15aa3eaa5a60c9f3f8e3e565303b465dbf57a1b730e7a2b9844e0", size = 251252 }, + { url = "https://files.pythonhosted.org/packages/93/52/3e39d26feae7df0aa0fd510b14012c3678b36ed068f7d78b8d8784d61f0e/psutil-5.9.8-cp37-abi3-win_amd64.whl", hash = "sha256:8db4c1b57507eef143a15a6884ca10f7c73876cdf5d51e713151c1236a0e68cf", size = 255090 }, + { url = "https://files.pythonhosted.org/packages/05/33/2d74d588408caedd065c2497bdb5ef83ce6082db01289a1e1147f6639802/psutil-5.9.8-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:d16bbddf0693323b8c6123dd804100241da461e41d6e332fb0ba6058f630f8c8", size = 249898 }, +] + +[[package]] +name = "pycares" +version = "4.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/7a/01ef7ce35fc1312d6c1c07f3b87f329ad6daf41bb9cd57c8f017e0b653fa/pycares-4.8.0.tar.gz", hash = "sha256:2fc2ebfab960f654b3e3cf08a732486950da99393a657f8b44618ad3ed2d39c1", size = 647980 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/00/3e2a78133d0f8975f90f34479853295f70d15538ae50912a79276425c7aa/pycares-4.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5e25db89005ddd8d9c5720293afe6d6dd92e682fc6bc7a632535b84511e2060d", size = 143823 }, + { url = "https://files.pythonhosted.org/packages/fb/13/feaa5b79bb78bbc980824e68ae140ee6c7a20a4d67ca7658f7e6ca51c399/pycares-4.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6f9665ef116e6ee216c396f5f927756c2164f9f3316aec7ff1a9a1e1e7ec9b2a", size = 138911 }, + { url = "https://files.pythonhosted.org/packages/c1/99/852f12333c9dc88c1d0761438ade6a5dd2af435f9cf8866da4422e3e8994/pycares-4.8.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:54a96893133471f6889b577147adcc21a480dbe316f56730871028379c8313f3", size = 585023 }, + { url = "https://files.pythonhosted.org/packages/ec/54/14dd74114a9fac98d586cb410926bf0dd1bc4be5884469d22a2ed97a2c18/pycares-4.8.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:51024b3a69762bd3100d94986a29922be15e13f56f991aaefb41f5bcd3d7f0bb", size = 625455 }, + { url = "https://files.pythonhosted.org/packages/63/68/ecdf374d90ffe66c616b98d01fd356426e14a0c5581c9ee072f83c9056d1/pycares-4.8.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:47ff9db50c599e4d965ae3bec99cc30941c1d2b0f078ec816680b70d052dd54a", size = 663415 }, + { url = "https://files.pythonhosted.org/packages/bf/fa/53dc3bab11452effcc4aec957cce6692f1b4f9f5d3fc0ced6199a500b868/pycares-4.8.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:27ef8ff4e0f60ea6769a60d1c3d1d2aefed1d832e7bb83fc3934884e2dba5cdd", size = 645721 }, + { url = "https://files.pythonhosted.org/packages/31/72/158a2c1f456b3674490db1788769f17fbf6d6b4481c46e7687485518bce0/pycares-4.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63511af7a3f9663f562fbb6bfa3591a259505d976e2aba1fa2da13dde43c6ca7", size = 626177 }, + { url = "https://files.pythonhosted.org/packages/bd/98/25a0c1dafa3c47d3a9ac1a542905f6b670189027bf6123ca8cb86845b726/pycares-4.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:73c3219b47616e6a5ad1810de96ed59721c7751f19b70ae7bf24997a8365408f", size = 619865 }, + { url = "https://files.pythonhosted.org/packages/14/50/7863fc2bec6ee20ccf3a42058c3393527f0473fd797044bcc9f91bd1fa21/pycares-4.8.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:da42a45207c18f37be5e491c14b6d1063cfe1e46620eb661735d0cedc2b59099", size = 591484 }, + { url = "https://files.pythonhosted.org/packages/d2/97/cdb364294f8d9cefa3d343e0d7e8330905654c768d8de0294eb85f78684d/pycares-4.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:8a068e898bb5dd09cd654e19cd2abf20f93d0cc59d5d955135ed48ea0f806aa1", size = 668562 }, + { url = "https://files.pythonhosted.org/packages/d8/78/26defce6fee0b3739ffc9a12eea9922e3995ba9b1425de016fb305f894e4/pycares-4.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:962aed95675bb66c0b785a2fbbd1bb58ce7f009e283e4ef5aaa4a1f2dc00d217", size = 651591 }, + { url = "https://files.pythonhosted.org/packages/a2/50/5114822c348347c46b01ccaac5dd81f791d93e984ec8f7f653e312e28590/pycares-4.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ce8b1a16c1e4517a82a0ebd7664783a327166a3764d844cf96b1fb7b9dd1e493", size = 625979 }, + { url = "https://files.pythonhosted.org/packages/6a/bd/ee4fbd142a5c544125418d0891446995900f77ae6844db1d40b2b7b3b5f6/pycares-4.8.0-cp311-cp311-win32.whl", hash = "sha256:b3749ddbcbd216376c3b53d42d8b640b457133f1a12b0e003f3838f953037ae7", size = 116672 }, + { url = "https://files.pythonhosted.org/packages/7d/17/411a14f77b342857f450e31e9c255adef9a1396f3453dd6acc9d22e621f7/pycares-4.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:5ce8a4e1b485b2360ab666c4ea1db97f57ede345a3b566d80bfa52b17e616610", size = 141955 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172 }, +] + +[[package]] +name = "pydantic" +version = "2.11.10" +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/ae/54/ecab642b3bed45f7d5f59b38443dcb36ef50f85af192e6ece103dbfe9587/pydantic-2.11.10.tar.gz", hash = "sha256:dc280f0982fbda6c38fada4e476dc0a4f3aeaf9c6ad4c28df68a666ec3c61423", size = 788494 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/1f/73c53fcbfb0b5a78f91176df41945ca466e71e9d9d836e5c522abda39ee7/pydantic-2.11.10-py3-none-any.whl", hash = "sha256:802a655709d49bd004c31e865ef37da30b540786a46bfce02333e0e24b5fe29a", size = 444823 }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584 }, + { url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071 }, + { url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823 }, + { url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792 }, + { url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338 }, + { url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998 }, + { url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200 }, + { url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890 }, + { url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359 }, + { url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883 }, + { url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074 }, + { url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538 }, + { url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909 }, + { url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786 }, + { url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200 }, + { url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123 }, + { url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852 }, + { url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484 }, + { url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896 }, + { url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475 }, + { url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013 }, + { url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715 }, + { url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757 }, +] + +[[package]] +name = "pyglet" +version = "1.5.31" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/c0/59130a7edbcc8f84e35870b00a712538ca05415ff02d17181277b8ef8f05/pyglet-1.5.31.zip", hash = "sha256:a5e422b4c27b0fc99e92103bf493109cca5c18143583b868b3b4631a98ae9417", size = 6900712 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/43/46aa0ab49f2f5145d201780c7595cf0c305fb4fb5d00d6639792a3d0e770/pyglet-1.5.31-py3-none-any.whl", hash = "sha256:f68413564bbec380e4815898fef0fb7a4a494dc3f8718bfbf28ce2a802634c88", size = 1143660 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274 }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pyparsing" +version = "3.0.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/71/22/207523d16464c40a0310d2d4d8926daffa00ac1f5b1576170a32db749636/pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb", size = 1999906 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/10/a7d0fa5baea8fe7b50f448ab742f26f52b80bfca85ac2be9d35cdd9a3246/pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc", size = 98338 }, +] + +[[package]] +name = "pyperclip" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/5b/55866e1cde0f86f5eec59dab5de8a66628cb0d53da74b8dbc15ad8dabda3/pyperclip-1.8.0.tar.gz", hash = "sha256:b75b975160428d84608c26edba2dec146e7799566aea42c1fe1b32e72b6028f2", size = 16529 } + +[[package]] +name = "pypng" +version = "0.20220715.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/93/cd/112f092ec27cca83e0516de0a3368dbd9128c187fb6b52aaaa7cde39c96d/pypng-0.20220715.0.tar.gz", hash = "sha256:739c433ba96f078315de54c0db975aee537cbc3e1d0ae4ed9aab0ca1e427e2c1", size = 128992 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/b9/3766cc361d93edb2ce81e2e1f87dd98f314d7d513877a342d31b30741680/pypng-0.20220715.0-py3-none-any.whl", hash = "sha256:4a43e969b8f5aaafb2a415536c1a8ec7e341cd6a3f957fd5b5f32a4cfeed902c", size = 58057 }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536 }, +] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095 }, +] + +[[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 } +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 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101 }, +] + +[[package]] +name = "python-multipart" +version = "0.0.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546 }, +] + +[[package]] +name = "pytz" +version = "2024.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/26/9f1f00a5d021fff16dee3de13d43e5e978f3d58928e129c3a62cf7eb9738/pytz-2024.1.tar.gz", hash = "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812", size = 316214 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/3d/a121f284241f08268b21359bd425f7d4825cffc5ac5cd0e1b3d82ffd2b10/pytz-2024.1-py2.py3-none-any.whl", hash = "sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319", size = 505474 }, +] + +[[package]] +name = "pyvers" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/62/21/9daf8da2793d112a04b46ac33ab9046c91b0af8292cd93b3cfdd07ff7169/pyvers-0.2.3.tar.gz", hash = "sha256:c4b81c3a033963245e124cdecb052783c9c4cea3bb08c051833af1c44faa6283", size = 12347 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/8d/63787e8feda59d981ebc953b6581ccab3c97cf7bfe0a6a407c07b50fc86c/pyvers-0.2.3-py3-none-any.whl", hash = "sha256:6f5b5612f2f4bd08caa49baa70fc5f875fc7da701a5385c13e244eea6b8114dd", size = 11757 }, +] + +[[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 }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825 }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875 }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612 }, + { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040 }, + { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829 }, + { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167 }, + { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952 }, + { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301 }, + { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638 }, + { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850 }, + { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980 }, +] + +[[package]] +name = "qdldl" +version = "0.1.7.post5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/88/9254835c513a381b8c6d52773060844acf76dfa739648c18f61809c8ee04/qdldl-0.1.7.post5.tar.gz", hash = "sha256:0b1399e1c49b5bed5aac8fd63ef08ab708d340c37fb426fe00128bc1f36b286e", size = 73920 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/6c/ce4cab36da9a7c0bff69067377b513ec88ff753de07f33f65959f4141308/qdldl-0.1.7.post5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aa22df45e625c763d129b2893b284b7bde16a535a7e900288d588be9dc24fe9f", size = 106139 }, + { url = "https://files.pythonhosted.org/packages/86/cf/641787a0c64019e76eb8bea925930005960323f1a5539361c209613f4747/qdldl-0.1.7.post5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7e196871dafe4febb86c2886713c8a2226d19455226e56e3b9480aa78eb59b5e", size = 103421 }, + { url = "https://files.pythonhosted.org/packages/be/87/91d2f0debdd515b653c701c023b939325c51157d74154336b8495f156659/qdldl-0.1.7.post5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ba5ff31a66d1f92b41d0b97d27288d28a8c849dd6db2221a579b1a5a5a6df0f", size = 1179946 }, + { url = "https://files.pythonhosted.org/packages/b8/7e/5fe5a081bd229a2b703a4b93e5ecaf44f51902e9b6a645c8ce4ea325ec0d/qdldl-0.1.7.post5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c34872867c2bcac60279034594eac8dee042b9dedd4c45948e55884b8c5c9cd0", size = 1193311 }, + { url = "https://files.pythonhosted.org/packages/53/dc/d6b760217f0fa7007e45c03dc0193c828ee5010f037acb58b79cd0010fbc/qdldl-0.1.7.post5-cp311-cp311-win_amd64.whl", hash = "sha256:b1280e886f734e3d0d67f643e3d76c55d2e23d0e7b06d89b987681dc165892c5", size = 90488 }, +] + +[[package]] +name = "qrcode" +version = "7.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "pypng" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/35/ad6d4c5a547fe9a5baf85a9edbafff93fc6394b014fab30595877305fa59/qrcode-7.4.2.tar.gz", hash = "sha256:9dd969454827e127dbd93696b20747239e6d540e082937c90f14ac95b30f5845", size = 535974 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/79/aaf0c1c7214f2632badb2771d770b1500d3d7cbdf2590ae62e721ec50584/qrcode-7.4.2-py3-none-any.whl", hash = "sha256:581dca7a029bcb2deef5d01068e39093e80ef00b4a61098a2182eac59d01643a", size = 46197 }, +] + +[[package]] +name = "regex" +version = "2026.7.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/98/04b13f1ddfb63158025291c02e03eb42fbb7acb51d091d541050eb4e35e8/regex-2026.7.19.tar.gz", hash = "sha256:7e77b324909c1617cbb4c668677e2c6ae13f44d7c1de0d4f15f2e3c10f3315b5", size = 416440 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/e5/cef4de2bac939280b68d32adc659478845238a8274f2f79c465063f590ad/regex-2026.7.19-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ac777001cdfc28b72477d93c8564bb7583081ea8fb45cdca3d568e0a4f87183c", size = 494012 }, + { url = "https://files.pythonhosted.org/packages/ff/87/e86f51eb117457bb7803132ffe5cb6e2841e2b5bea4cc85d397f3c6e257d/regex-2026.7.19-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:59787bd5f8c70aa339084e961d2996b53fbdeab4d5393bba5c1fe1fc32e02bae", size = 295281 }, + { url = "https://files.pythonhosted.org/packages/41/2e/2360c41d8080a3d9ec7e5c90fad6eab3b50192869d10e9a5609e48c8177b/regex-2026.7.19-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:90c633e7e8d6bf4e992b8b36ce69e018f834b641dd6de8cea6d78c06ffa119c5", size = 290615 }, + { url = "https://files.pythonhosted.org/packages/cf/69/b65ba4344efbc771b28fe5dde84cbbb6c8f9551165952fe78def5b9dde6a/regex-2026.7.19-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:87ccab0db8d5f4fbb0272642113c1adb2ffc698c16d3a0944580222331fa7a20", size = 791804 }, + { url = "https://files.pythonhosted.org/packages/81/b6/a40dfa0dc6224b36f620c00296eacc830489cbf8c2837b6750dfe6170375/regex-2026.7.19-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e50d748a32da622f256e8d505867f5d3c43a837c6a9f0efb149655fadd1042a", size = 861723 }, + { url = "https://files.pythonhosted.org/packages/e3/02/735991dee71abd83196a7962f7ed8bf5aa05720ff06e2d3ff896a85e2bbb/regex-2026.7.19-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf1516fe58fc104f39b2d1dbe2d5e27d0cd45c4be2e42ba6ee0cc763701ec3c7", size = 905932 }, + { url = "https://files.pythonhosted.org/packages/45/6c/e7098d8b846ccdbf431d8c081b61e496526a27a28094ed09e0dce21b3f54/regex-2026.7.19-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:09f3e5287f94f17b709dc9a9e70865855feee835c861613be144218ce4ca82cc", size = 801407 }, + { url = "https://files.pythonhosted.org/packages/8a/18/34b69274e2649bcc7d9b089c2b2983fb2632d8ecf667e359593be9072e79/regex-2026.7.19-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6383cd2ed53a646c659ba1fe65727db76437fdaa069e697a0b44a51d5843d864", size = 774448 }, + { url = "https://files.pythonhosted.org/packages/bb/e6/0a72247d025585fd3800b98e040b84d562a88af6303347100484849f4f01/regex-2026.7.19-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:09d3007fc76249a83cdd33de160d50e6cb77f54e09d8fa9e7148e10607ce24af", size = 783297 }, + { url = "https://files.pythonhosted.org/packages/b1/aa/c4f65ae7dd02a36b323a70c4cff326e1f3442361aaebc9311100a130d54f/regex-2026.7.19-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6f8c6e7a1cfa3dc9d0ee2de0e65e834537fa29992cc3976ffec914afc35c5dd5", size = 854736 }, + { url = "https://files.pythonhosted.org/packages/62/c3/668082bcc817b9e694189b84997aeba7385b7779faa6711788679c482e35/regex-2026.7.19-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b2ea4a3e8357be8849e833beeae757ac3c7a6b3fc055c03c808a53c91ad30d82", size = 763298 }, + { url = "https://files.pythonhosted.org/packages/4b/fb/2d07ad555e7af88aa5f867fdafa47a8d945ee237c20af3ebceb46a820835/regex-2026.7.19-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:80115dd39481fd3a4b4080220799dbcacb921a844de4b827264ececacbe17c78", size = 844430 }, + { url = "https://files.pythonhosted.org/packages/51/15/c82a471fe3dce56f03745635b43aa456c40dc0db089e07ef148b331507d1/regex-2026.7.19-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d6ce43a0269d68cee79a7d1ade7def53c20f8f2a047b92d7b5d5bcc73ae88327", size = 789683 }, + { url = "https://files.pythonhosted.org/packages/b5/f4/7532a2c59d56f5398902c20de60f0c9a5d1cd364e42a051b48e1b210be7b/regex-2026.7.19-cp311-cp311-win32.whl", hash = "sha256:9be2a6647740dd3cca6acb24e87f03d7632cd280dbce9bbe40c26353a215a45d", size = 266778 }, + { url = "https://files.pythonhosted.org/packages/83/2b/cf1bc631db154eb95520d9d5dbc2371ff77a0f014bbf7d748fed8496aa63/regex-2026.7.19-cp311-cp311-win_amd64.whl", hash = "sha256:8d3469c91dd92ee41b7c95280edbd975ef1ba9195086686623a1c6e8935ce965", size = 277983 }, + { url = "https://files.pythonhosted.org/packages/8d/bd/56ceaf170e875d5a6761bf2bfd0d040f1cacc896850d5e40cb29b11bbd06/regex-2026.7.19-cp311-cp311-win_arm64.whl", hash = "sha256:36aacfb15faaff3ced55afbf35ec72f50d4aee22082c4f7fe0573a33e2fca92e", size = 276961 }, +] + +[[package]] +name = "requests" +version = "2.32.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, +] + +[[package]] +name = "requests-oauthlib" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "oauthlib" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/52/531ef197b426646f26b53815a7d2a67cb7a331ef098bb276db26a68ac49f/requests-oauthlib-1.3.1.tar.gz", hash = "sha256:75beac4a47881eeb94d5ea5d6ad31ef88856affe2332b9aafb52c6452ccf0d7a", size = 52027 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/bb/5deac77a9af870143c684ab46a7934038a53eb4aa975bc0687ed6ca2c610/requests_oauthlib-1.3.1-py2.py3-none-any.whl", hash = "sha256:2577c501a2fb8d05a304c09d090d6e47c306fef15809d102b327cf8364bddab5", size = 23892 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654 }, +] + +[[package]] +name = "rsl-rl-lib" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitpython" }, + { name = "numpy" }, + { name = "onnx" }, + { name = "tensordict" }, + { name = "torch" }, + { name = "torchvision", version = "0.22.0", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, + { name = "torchvision", version = "0.22.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/b9/41263b8cb13c6bd648d72311bcef44820d3f78cdfd5f8407b0065275850a/rsl_rl_lib-3.0.1.tar.gz", hash = "sha256:425520246e2262a964d42118d00f20abc516e7b7cc2e38cd3468e18627383a53", size = 51537 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/fc/00741bea22dfcb242b68937c6e60c59a54e9e2adef23dd77d0b7a0c10c08/rsl_rl_lib-3.0.1-py3-none-any.whl", hash = "sha256:89ac8b1792cf542b4ade9487b4fd44d6b42112a852fa59c903704b180ae08adc", size = 48989 }, +] + +[[package]] +name = "rtree" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/79/44fdc619e87bd7b5388f76418719bd8b99de5565475f74a2e0d82b401062/rtree-1.3.0.tar.gz", hash = "sha256:b36e9dd2dc60ffe3d02e367242d2c26f7281b00e1aaf0c39590442edaaadd916", size = 48190 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/cc/1b494bde9c99a5cf27e980bf36ef99e76abac6316736231007c04e3a7b28/Rtree-1.3.0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:80879d9db282a2273ca3a0d896c84583940e9777477727a277624ebfd424c517", size = 475526 }, + { url = "https://files.pythonhosted.org/packages/dd/5b/085d6fad9d45c0cc2acbea5b78c3a2d7f1e7ccc7c05929633461a6a741d8/Rtree-1.3.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4328e9e421797c347e6eb08efbbade962fe3664ebd60c1dffe82c40911b1e125", size = 432890 }, + { url = "https://files.pythonhosted.org/packages/12/70/f0553ffb163c47a62c09e4bdc5e0c7fb3392a03cd5a3dbde965aa6a85052/Rtree-1.3.0-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:037130d3ce1fc029de81941ec416ba5546f66228380ba19bb41f2ea1294e8423", size = 500384 }, + { url = "https://files.pythonhosted.org/packages/4e/92/3c972e534ce0508214b9ed0cfeba03d1e26d193e8fa624131b5324b91b25/Rtree-1.3.0-py3-none-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:864a05d0c3b7ce6c5e34378b7ab630057603b79179368bc50624258bdf2ff631", size = 569246 }, + { url = "https://files.pythonhosted.org/packages/70/db/6c8bc20061572c33766ade296071d0127e7365d4d3ff54a6c2c075de637b/Rtree-1.3.0-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ec2ed6d1635753dab966e68f592a9c4896f3f4ec6ad2b09b776d592eacd883a9", size = 543195 }, + { url = "https://files.pythonhosted.org/packages/71/2c/5d04fa6010f2d4d4b38078efdc6f371430f499ef2cf7eeced3d18f57daaa/Rtree-1.3.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b4485fb3e5c5e85b94a95f0a930a3848e040d2699cfb012940ba5b0130f1e09a", size = 1416562 }, + { url = "https://files.pythonhosted.org/packages/b6/63/0a2bee2940a8ba116d845ac8b360e49c315a57aeb4aa92ea12a4cb84eb4f/Rtree-1.3.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7e2e9211f4fb404c06a08fd2cbebb03234214f73c51913bb371c3d9954e99cc9", size = 1630693 }, + { url = "https://files.pythonhosted.org/packages/10/8a/8a50fc8d58807ba5780485ecc502136aa814f6a08e1cce4f9c4f109ba2b4/Rtree-1.3.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c021f4772b25cc24915da8073e553ded6fa8d0b317caa4202255ed26b2344c1c", size = 1506863 }, + { url = "https://files.pythonhosted.org/packages/85/d2/5bb7617faa3b23b51e2259f9d23e0b33f6ff0ed9811b0d05511e9b7ed84e/Rtree-1.3.0-py3-none-win_amd64.whl", hash = "sha256:97f835801d24c10bbf02381abe5e327345c8296ec711dde7658792376abafc66", size = 377458 }, +] + +[[package]] +name = "s3transfer" +version = "0.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/05/d52bf1e65044b4e5e27d4e63e8d1579dbdec54fce685908ae09bc3720030/s3transfer-0.13.1.tar.gz", hash = "sha256:c3fdba22ba1bd367922f27ec8032d6a1cf5f10c934fb5d68cf60fd5a23d936cf", size = 150589 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/4f/d073e09df851cfa251ef7840007d04db3293a0482ce607d2b993926089be/s3transfer-0.13.1-py3-none-any.whl", hash = "sha256:a981aa7429be23fe6dfc13e80e4020057cbab622b08c0315288758d67cabc724", size = 85308 }, +] + +[[package]] +name = "safetensors" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/06/f955dbbb1859e3bd23c8ac6141af5106e7ad5fedec4a3a6e3d60f94b7001/safetensors-0.8.0.tar.gz", hash = "sha256:fabaf3e0f18a6618d9b36560682562157f77c2b71fcffc7b432be2baed9d753d", size = 325846 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/a0/f718cda65b05407d228f97602cf60dca269c979867aa5beb25410de26cd3/safetensors-0.8.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c554f85858e05226d3c2828e32395e677434685d6d94594a41643361c5e837f0", size = 473568 }, + { url = "https://files.pythonhosted.org/packages/f5/b1/fa7c600e7dceae12e9606c7578cbc9ff1e1ed55844883ee5c92205e86226/safetensors-0.8.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c80201d22cbf405b80647a60ada77bba06c8fba2da2743ba1e89cdcc39a81f25", size = 484562 }, + { url = "https://files.pythonhosted.org/packages/09/7d/65a7de0af421317bb36a067241e4235fff194eed60b961ed6d3f59a3fc60/safetensors-0.8.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a46e5ff292c356d6991e60942ba7f79817682d3a2cef0702136448cb9c4d235", size = 502844 }, + { url = "https://files.pythonhosted.org/packages/91/4f/3175c9d75634e0e0dda0082794193521035edd7c70a6f212bf33ca06ddf4/safetensors-0.8.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4124502b78f03534117c848f87a39b8f31e577b15eff423bf8bfb95f2a8c30d0", size = 511823 }, + { url = "https://files.pythonhosted.org/packages/20/87/846c289e7aa2299eff406335717cf43ce8777194ece8aad75772e0411615/safetensors-0.8.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7bc0a787ba8a35be368ee3574edfa2b1ad389eebd0a72e482ae275490e3f6c98", size = 633461 }, + { url = "https://files.pythonhosted.org/packages/76/22/8d64d9df2c45d5ded401df889d0ad90882804ca172d79ec4f0df8f727fe0/safetensors-0.8.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:040070828e36dc8e122178bbbd5830ff9e97920affb84cbe0f46442497bed358", size = 545148 }, + { url = "https://files.pythonhosted.org/packages/28/50/f203ff3a3ddfe19308efc83c5a3a29ed02bf786732ec35e68bf9162f3365/safetensors-0.8.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd6f3f93c9a0a7cc2788ee63fb763353d4bd2e89b0751bc78fcf7dda00bea774", size = 516040 }, + { url = "https://files.pythonhosted.org/packages/46/fb/cdaed17ceb2948784fd9c36b6fd3e951b608547cea81a48e8ee6f8cfdfcb/safetensors-0.8.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:fcdd41ec4628fee5799f807c73c353629130fbd942aa23d83c623dd6c9d52d78", size = 513832 }, + { url = "https://files.pythonhosted.org/packages/0d/49/1e15de264dcc3b77943d2d0c56a95809956883b1c2d6d585c792523f180b/safetensors-0.8.0-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8e9f537aa183a38ace122d27303dcd986b26bd2a7591f9181d7f0c396f4677ca", size = 559930 }, + { url = "https://files.pythonhosted.org/packages/2a/43/bf38443278eab4b1be1fce2931e2b012ad9cb7df52ada751d0aab8f7659a/safetensors-0.8.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:87eec7ffed2b809f05a398a8becb7d013f19f7837cd15d9748580d6cf30dbaf4", size = 678670 }, + { url = "https://files.pythonhosted.org/packages/72/e3/68cd3fa5b48488e84add63e04cb12f3bc28ae4638c06d4508c6e88823d0e/safetensors-0.8.0-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:4a95ae2b05d7726d751da4ebf626a2ca782b706e101bd894c95bc2450b1cffcc", size = 786679 }, + { url = "https://files.pythonhosted.org/packages/29/4b/1c19c509d56e01f4fbb3d0a2e597450f6cc04d1d56cf52defb0a62dfd715/safetensors-0.8.0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae091f16662658bdc019a4ff6cb4c085bb7d725eb5978b183ffd265863b6d2d", size = 765683 }, + { url = "https://files.pythonhosted.org/packages/27/43/41c1621732edd934d868a00d1b891584c892a7b62a9aab82ea5a0a5623ee/safetensors-0.8.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8e080062fcde23be189565e1c3305d16751a218ecf9412c8601e64204eb6f846", size = 722361 }, + { url = "https://files.pythonhosted.org/packages/8e/3f/73ccf82579412b4a71c4ca673f10b5f1f888d7cf5af7fe24f27d30307be4/safetensors-0.8.0-cp310-abi3-win32.whl", hash = "sha256:2ddf52eac562eda224f99acfa7889d02968c1fd59a5b011ae7d8137c37e9c02d", size = 342401 }, + { url = "https://files.pythonhosted.org/packages/1b/6d/3fba214c1e5e0f69991677ec3bc17023f0421776975e1de0c682dca475e2/safetensors-0.8.0-cp310-abi3-win_amd64.whl", hash = "sha256:096ec1a98435df7beb08853bb5aa9081a84f23d0adc67ed1a0a10550f608373f", size = 355540 }, + { url = "https://files.pythonhosted.org/packages/8d/fc/7eedc3510d97878876e32774eebbeb61c43f148a96e915c84229a3e967aa/safetensors-0.8.0-cp310-abi3-win_arm64.whl", hash = "sha256:f7838e5135a406ad3e02efdcb8cf2e5397d368b0154537c4fec682dbc544d452", size = 340500 }, +] + +[[package]] +name = "scipy" +version = "1.15.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214 } +wheels = [ + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/ab/a7/0ddaf514ce8a8714f6ed243a2b391b41dbb65251affe21ee3077ec45ea9a/scipy-1.15.3-cp311-cp311-win_amd64.whl", hash = "sha256:ae48a786a28412d744c62fd7816a4118ef97e5be0bee968ce8f0a2fba7acf3bb", size = 41246504 }, +] + +[[package]] +name = "sentry-sdk" +version = "2.29.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/67/d552a5f8e5a6a56b2feea6529e2d8ccd54349084c84176d5a1f7295044bc/sentry_sdk-2.29.1.tar.gz", hash = "sha256:8d4a0206b95fa5fe85e5e7517ed662e3888374bdc342c00e435e10e6d831aa6d", size = 325518 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/e5/da07b0bd832cefd52d16f2b9bbbe31624d57552602c06631686b93ccb1bd/sentry_sdk-2.29.1-py2.py3-none-any.whl", hash = "sha256:90862fe0616ded4572da6c9dadb363121a1ae49a49e21c418f0634e9d10b4c19", size = 341553 }, +] + +[[package]] +name = "setuptools" +version = "84.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, +] + +[[package]] +name = "skrl" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gymnasium" }, + { name = "packaging" }, + { name = "tensorboard" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/bb/8ac912d477c7e18065281021e3d68db2d91034a09ff1668247733490703c/skrl-2.1.0.tar.gz", hash = "sha256:4a1c925b5e025cda3133023331a46f4197ddc5ce420abc01b95e934235402f08", size = 253522 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/de/eca16c14e27a986ddc0314711a6bc42bb70c247073c7f62c390e4ffaa5ed/skrl-2.1.0-py3-none-any.whl", hash = "sha256:5d0cf61fcf81243018220dacbd4a04c84250c1e67274aaeac1779cd53db44e23", size = 458097 }, +] + +[[package]] +name = "smmap" +version = "5.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390 }, +] + +[[package]] +name = "stable-baselines3" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cloudpickle" }, + { name = "gymnasium" }, + { name = "matplotlib" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "torch" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e2/07/01a2aa6a6a58f911085981dad1af2f1877d04886768c0df33930945fb98c/stable_baselines3-2.8.0.tar.gz", hash = "sha256:fe976d102b596c8001ca619638901721bcf97e8934a397e235b2d277fa9216c2", size = 220224 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/1b/8046baf1e7756006eab991cd3260c4342c515dd6b536bc2d03df4b6f35aa/stable_baselines3-2.8.0-py3-none-any.whl", hash = "sha256:8c19d960b534a909f46dac5227662fc2d6be380e5c66cb04e1ad23edb23dc5a2", size = 187458 }, +] + +[[package]] +name = "starlette" +version = "0.45.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/fb/2984a686808b89a6781526129a4b51266f678b2d2b97ab2d325e56116df8/starlette-0.45.3.tar.gz", hash = "sha256:2cbcba2a75806f8a41c722141486f37c28e30a0921c5f6fe4346cb0dcee1302f", size = 2574076 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/61/f2b52e107b1fc8944b33ef56bf6ac4ebbe16d91b94d2b87ce013bf63fb84/starlette-0.45.3-py3-none-any.whl", hash = "sha256:dfb6d332576f136ec740296c7e8bb8c8a7125044e7c6da30744718880cdd059d", size = 71507 }, +] + +[[package]] +name = "sympy" +version = "1.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/11/8a/5a7fd6284fa8caac23a26c9ddf9c30485a48169344b4bd3b0f02fef1890f/sympy-1.13.3.tar.gz", hash = "sha256:b27fd2c6530e0ab39e275fc9b683895367e51d5da91baa8d3d64db2565fec4d9", size = 7533196 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/ff/c87e0622b1dadea79d2fb0b25ade9ed98954c9033722eb707053d310d4f3/sympy-1.13.3-py3-none-any.whl", hash = "sha256:54612cf55a62755ee71824ce692986f23c88ffa77207b30c1368eda4a7060f73", size = 6189483 }, +] + +[[package]] +name = "tensorboard" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "absl-py" }, + { name = "grpcio" }, + { name = "markdown" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "protobuf" }, + { name = "setuptools" }, + { name = "tensorboard-data-server" }, + { name = "werkzeug" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/4b/cd2eec9642781a8f5b2fb9994e3933a7b259ab18e9d49aeede9b5acf6311/tensorboard-2.21.0-py3-none-any.whl", hash = "sha256:7279316dcb6bd5bc391d623dea841531299cde1887310e8133bc34a996d32255", size = 5516204 }, +] + +[[package]] +name = "tensorboard-data-server" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/13/e503968fefabd4c6b2650af21e110aa8466fe21432cd7c43a84577a89438/tensorboard_data_server-0.7.2-py3-none-any.whl", hash = "sha256:7e0610d205889588983836ec05dc098e80f97b7e7bbff7e994ebb78f578d0ddb", size = 2356 }, + { url = "https://files.pythonhosted.org/packages/b7/85/dabeaf902892922777492e1d253bb7e1264cadce3cea932f7ff599e53fea/tensorboard_data_server-0.7.2-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:9fe5d24221b29625dbc7328b0436ca7fc1c23de4acf4d272f1180856e32f9f60", size = 4823598 }, + { url = "https://files.pythonhosted.org/packages/73/c6/825dab04195756cf8ff2e12698f22513b3db2f64925bdd41671bfb33aaa5/tensorboard_data_server-0.7.2-py3-none-manylinux_2_31_x86_64.whl", hash = "sha256:ef687163c24185ae9754ed5650eb5bc4d84ff257aabdc33f0cc6f74d8ba54530", size = 6590363 }, +] + +[[package]] +name = "tensordict" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cloudpickle" }, + { name = "importlib-metadata" }, + { name = "numpy" }, + { name = "orjson" }, + { name = "packaging" }, + { name = "pyvers" }, + { name = "torch" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/f0/0f2a24dbfda9d7d38b352421231c5bf7aa60da67748a9dcb686afc3ab3e5/tensordict-0.14.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:8caf6d8b07bd14cea26464f774c4a929d6d035de1b7a3d6aaaf19f8f6dae8177", size = 944550 }, + { url = "https://files.pythonhosted.org/packages/e7/ae/04418adde2f4383c82bfa5b8c02dad71fcaa8bd8c660052dbdc808a20bfd/tensordict-0.14.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:4cfb07896a4a16533255771c45c08f123aeba02760d54d5cba2f204471edc97d", size = 588849 }, + { url = "https://files.pythonhosted.org/packages/0a/9b/5707c3fe07dda56ff44c2518f03eb86a56c02af5ed036771c9bfb4a899e5/tensordict-0.14.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:8ed358fcbf9b8460fd8b5b8d73f42653098ee04bc7550d3d93c82586ead58dbc", size = 593431 }, + { url = "https://files.pythonhosted.org/packages/b3/ea/64f0f4350c22af4400537b5f14096f04f6e6e4802bbfe09db63aa59946e1/tensordict-0.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:2cd6b24d188c2dc1f4273e556984c6e3e2da810d9b7304e3672078994674492f", size = 642439 }, +] + +[[package]] +name = "tokenizers" +version = "0.22.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275 }, + { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472 }, + { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736 }, + { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835 }, + { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673 }, + { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818 }, + { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195 }, + { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982 }, + { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245 }, + { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069 }, + { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263 }, + { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429 }, + { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363 }, + { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786 }, + { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133 }, +] + +[[package]] +name = "toml" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588 }, +] + +[[package]] +name = "torch" +version = "2.7.0+cu128" +source = { registry = "https://download.pytorch.org/whl/cu128" } +dependencies = [ + { name = "filelock" }, + { name = "fsspec" }, + { name = "jinja2" }, + { name = "networkx" }, + { name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-cupti-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-nvrtc-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-runtime-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cudnn-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cufft-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cufile-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-curand-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cusolver-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cusparse-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cusparselt-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-nccl-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-nvjitlink-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-nvtx-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "sympy" }, + { name = "triton", marker = "sys_platform == 'linux'" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://download-r2.pytorch.org/whl/cu128/torch-2.7.0%2Bcu128-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:47c895bcab508769d129d717a4b916b10225ae3855723aeec8dff8efe5346207" }, + { url = "https://download-r2.pytorch.org/whl/cu128/torch-2.7.0%2Bcu128-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:c4bbc0b4be60319ba1cefc90be9557b317f0b3c261eeceb96ca6e0343eec56bf" }, + { url = "https://download-r2.pytorch.org/whl/cu128/torch-2.7.0%2Bcu128-cp311-cp311-win_amd64.whl", hash = "sha256:bf88f647d76d79da9556ca55df49e45aff1d66c12797886364343179dd09a36c" }, +] + +[[package]] +name = "torchaudio" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "torch" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/d6/27deb8862ecc005c95a5c64bcc8cc27c74878eb8d4162ce4d39b35ea9e27/torchaudio-2.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:862d9c5cfe15688a7846962b5d3c9f959beffe82b1e5441935c7a37504c5c5e7", size = 1849075 }, + { url = "https://files.pythonhosted.org/packages/04/95/29b4a4d87540779101cb60cb7f381fdb6bc6aea0af83f0f35aa8fc70cb0d/torchaudio-2.7.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:677bd32031310ee73a47d6eebc2e74e74c1cf467932945ee88082a3935b5c950", size = 1686165 }, + { url = "https://files.pythonhosted.org/packages/ab/20/1873a49df9f1778c241543eaca14d613d657b9f9351c254952114251cb86/torchaudio-2.7.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:c37b77dd528ad18a036466e856f53d8bd5912b757a775309354b4a977a069379", size = 3455781 }, + { url = "https://files.pythonhosted.org/packages/9e/1d/1fa4f69e4cd8c83831c3baad0ac9b56ece8ce0e75e5e5c0cdd3f591a458c/torchaudio-2.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:36b94819f5406b2599ac31542e2e7a7aaf4a5b5f466ce034f296b1ee1134c945", size = 2494793 }, +] + +[[package]] +name = "torchvision" +version = "0.22.0" +source = { registry = "https://download.pytorch.org/whl/cu128" } +resolution-markers = [ + "platform_machine == 'aarch64' and sys_platform == 'linux'", +] +dependencies = [ + { name = "numpy", marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, + { name = "pillow", marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, + { name = "torch", marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, +] +wheels = [ + { url = "https://download-r2.pytorch.org/whl/cu128/torchvision-0.22.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:6be714bcdd8849549571f6acfaa2dfa9e00676f042bda517432745fb116f7904" }, +] + +[[package]] +name = "torchvision" +version = "0.22.0+cu128" +source = { registry = "https://download.pytorch.org/whl/cu128" } +resolution-markers = [ + "platform_machine != 's390x' and sys_platform == 'darwin'", + "platform_machine == 's390x' and sys_platform == 'darwin'", + "(platform_machine != 'aarch64' and platform_machine != 's390x' and sys_platform == 'linux') or (platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')", + "platform_machine == 's390x' and sys_platform != 'darwin' and sys_platform != 'win32'", + "platform_machine != 's390x' and sys_platform == 'win32'", + "platform_machine == 's390x' and sys_platform == 'win32'", +] +dependencies = [ + { name = "numpy", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "pillow", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "torch", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, +] +wheels = [ + { url = "https://download-r2.pytorch.org/whl/cu128/torchvision-0.22.0%2Bcu128-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:f3ac527d58b4c2043eb8d9e29fc56cd1751f36f2aaa6dc75e34ec54c951bcb9c" }, + { url = "https://download-r2.pytorch.org/whl/cu128/torchvision-0.22.0%2Bcu128-cp311-cp311-win_amd64.whl", hash = "sha256:f5dae1307c34813425c0b753530c035e1cc72af0bded395d1ba64dcb2872889f" }, +] + +[[package]] +name = "tornado" +version = "6.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/89/c72771c81d25d53fe33e3dca61c233b665b2780f21820ba6fd2c6793c12b/tornado-6.5.1.tar.gz", hash = "sha256:84ceece391e8eb9b2b95578db65e920d2a61070260594819589609ba9bc6308c", size = 509934 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/89/f4532dee6843c9e0ebc4e28d4be04c67f54f60813e4bf73d595fe7567452/tornado-6.5.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d50065ba7fd11d3bd41bcad0825227cc9a95154bad83239357094c36708001f7", size = 441948 }, + { url = "https://files.pythonhosted.org/packages/15/9a/557406b62cffa395d18772e0cdcf03bed2fff03b374677348eef9f6a3792/tornado-6.5.1-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9e9ca370f717997cb85606d074b0e5b247282cf5e2e1611568b8821afe0342d6", size = 440112 }, + { url = "https://files.pythonhosted.org/packages/55/82/7721b7319013a3cf881f4dffa4f60ceff07b31b394e459984e7a36dc99ec/tornado-6.5.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b77e9dfa7ed69754a54c89d82ef746398be82f749df69c4d3abe75c4d1ff4888", size = 443672 }, + { url = "https://files.pythonhosted.org/packages/7d/42/d11c4376e7d101171b94e03cef0cbce43e823ed6567ceda571f54cf6e3ce/tornado-6.5.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:253b76040ee3bab8bcf7ba9feb136436a3787208717a1fb9f2c16b744fba7331", size = 443019 }, + { url = "https://files.pythonhosted.org/packages/7d/f7/0c48ba992d875521ac761e6e04b0a1750f8150ae42ea26df1852d6a98942/tornado-6.5.1-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:308473f4cc5a76227157cdf904de33ac268af770b2c5f05ca6c1161d82fdd95e", size = 443252 }, + { url = "https://files.pythonhosted.org/packages/89/46/d8d7413d11987e316df4ad42e16023cd62666a3c0dfa1518ffa30b8df06c/tornado-6.5.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:caec6314ce8a81cf69bd89909f4b633b9f523834dc1a352021775d45e51d9401", size = 443930 }, + { url = "https://files.pythonhosted.org/packages/78/b2/f8049221c96a06df89bed68260e8ca94beca5ea532ffc63b1175ad31f9cc/tornado-6.5.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:13ce6e3396c24e2808774741331638ee6c2f50b114b97a55c5b442df65fd9692", size = 443351 }, + { url = "https://files.pythonhosted.org/packages/76/ff/6a0079e65b326cc222a54720a748e04a4db246870c4da54ece4577bfa702/tornado-6.5.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5cae6145f4cdf5ab24744526cc0f55a17d76f02c98f4cff9daa08ae9a217448a", size = 443328 }, + { url = "https://files.pythonhosted.org/packages/49/18/e3f902a1d21f14035b5bc6246a8c0f51e0eef562ace3a2cea403c1fb7021/tornado-6.5.1-cp39-abi3-win32.whl", hash = "sha256:e0a36e1bc684dca10b1aa75a31df8bdfed656831489bc1e6a6ebed05dc1ec365", size = 444396 }, + { url = "https://files.pythonhosted.org/packages/7b/09/6526e32bf1049ee7de3bebba81572673b19a2a8541f795d887e92af1a8bc/tornado-6.5.1-cp39-abi3-win_amd64.whl", hash = "sha256:908e7d64567cecd4c2b458075589a775063453aeb1d2a1853eedb806922f568b", size = 444840 }, + { url = "https://files.pythonhosted.org/packages/55/a7/535c44c7bea4578e48281d83c615219f3ab19e6abc67625ef637c73987be/tornado-6.5.1-cp39-abi3-win_arm64.whl", hash = "sha256:02420a0eb7bf617257b9935e2b754d1b63897525d8a289c9d65690d580b4dcf7", size = 443596 }, +] + +[[package]] +name = "tqdm" +version = "4.70.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184 }, +] + +[[package]] +name = "transformers" +version = "5.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "regex" }, + { name = "safetensors" }, + { name = "tokenizers" }, + { name = "tqdm" }, + { name = "typer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/3f/d89353267d511e18f137dfd7769d07837350c11b88408ce1dfe2e93e56c7/transformers-5.15.0.tar.gz", hash = "sha256:bbf98f57b2ddd7c4ecbccfa2c0069017aa6fd01cc204bd50cbc0eeadcf2a13b8", size = 9377983 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/43/81355710a4c84e9420e11a86d41a5364deb561f2ef36dfdf254a07371bbb/transformers-5.15.0-py3-none-any.whl", hash = "sha256:d7f007736f67749ae9490c4f8cb5d30b452ae2d68c8675e50ba8d63ea7feb107", size = 11749280 }, +] + +[[package]] +name = "trimesh" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/18/fe1b29de4d4739ec2c96351b3d104512acbf1181e0a9516ebf3ccf6e548f/trimesh-4.5.1.tar.gz", hash = "sha256:2e85179fdbee1e872aa00d42a5b28605a05302968857a8321a4dfa4390725b1c", size = 788296 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/45/a0d1b0474f280b5191ce6be739533514c9fe4a0315aed75ed58741d536c7/trimesh-4.5.1-py3-none-any.whl", hash = "sha256:fe2ddcf5b091e4d93bb7b689b947b84ad7a62ca862225f2c70cef1caaac4bd32", size = 703652 }, +] + +[[package]] +name = "triton" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "setuptools" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/c5/4874a81131cc9e934d88377fbc9d24319ae1fb540f3333b4e9c696ebc607/triton-3.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3161a2bf073d6b22c4e2f33f951f3e5e3001462b2570e6df9cd57565bdec2984", size = 156528461 }, +] + +[[package]] +name = "typer" +version = "0.27.1" +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/ae/40/4a3db7990d1f62a53182aa96eaef57aeb2886a27f90a195bc66713565d31/typer-0.27.1.tar.gz", hash = "sha256:a79bef8469a79c45498e7b814ecf8d603cc7644e9acbd9e19cac0334240b18df", size = 203994 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/89/9518bc0c3929bee36b3a4a8e3daddd6e03f92f9961c66d4983b837160543/typer-0.27.1-py3-none-any.whl", hash = "sha256:53150287edd11baeb4e4722c8e394fcdf8181c0ae89485cba8d25c778d5edd56", size = 122874 }, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611 }, +] + +[[package]] +name = "tzdata" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087 }, +] + +[[package]] +name = "uvicorn" +version = "0.29.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/8d/5005d39cd79c9ae87baf7d7aafdcdfe0b13aa69d9a1e3b7f1c984a2ac6d2/uvicorn-0.29.0.tar.gz", hash = "sha256:6a69214c0b6a087462412670b3ef21224fa48cae0e452b5883e8e8bdfdd11dd0", size = 40894 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/f5/cbb16fcbe277c1e0b8b3ddd188f2df0e0947f545c49119b589643632d156/uvicorn-0.29.0-py3-none-any.whl", hash = "sha256:2c2aac7ff4f4365c206fd773a39bf4ebd1047c238f8b8268ad996829323473de", size = 60813 }, +] + +[[package]] +name = "warp-lang" +version = "1.16.0" +source = { registry = "https://pypi.nvidia.com/" } +dependencies = [ + { name = "numpy" }, +] +wheels = [ + { url = "https://pypi.nvidia.com/warp-lang/warp_lang-1.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:19a7590c4484e8250ab19165311d2a1774138663b361c41e8be2865c7515c4ae" }, + { url = "https://pypi.nvidia.com/warp-lang/warp_lang-1.16.0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:96449fc1e3b354185e2f09434fb794b5953ab2e8673b104d7e7f48d5d418bb35" }, + { url = "https://pypi.nvidia.com/warp-lang/warp_lang-1.16.0-py3-none-manylinux_2_34_aarch64.whl", hash = "sha256:d4715171bd6821436b82293d774c9a082ace08215c189572b4348d1e48fb8ee8" }, + { url = "https://pypi.nvidia.com/warp-lang/warp_lang-1.16.0-py3-none-win_amd64.whl", hash = "sha256:8690c9096e0a271985339d4aa4b37675e7d62852620ca861ac032dc85b124542" }, +] + +[[package]] +name = "watchdog" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/3c/43eeaa9ea17a2657d639aa3827beaa77042809410f86fb76f0d0ea6a2102/watchdog-4.0.0.tar.gz", hash = "sha256:e3e7065cbdabe6183ab82199d7a4f6b3ba0a438c5a512a68559846ccb76a78ec", size = 126415 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/60/04c6ee1a950b8f193ad4a766dd0518663829dc64603699ab1ba2f53e78f8/watchdog-4.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8350d4055505412a426b6ad8c521bc7d367d1637a762c70fdd93a3a0d595990b", size = 102144 }, + { url = "https://files.pythonhosted.org/packages/7f/9b/04110f5c61fe2a90d5cccfc6445c8cce8c560c2fae236571c397b0dd68d0/watchdog-4.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c17d98799f32e3f55f181f19dd2021d762eb38fdd381b4a748b9f5a36738e935", size = 92695 }, + { url = "https://files.pythonhosted.org/packages/fb/01/2275fe6a5598daf95b9e44cc10a4db642c637ae00986836478e01eaccb4f/watchdog-4.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4986db5e8880b0e6b7cd52ba36255d4793bf5cdc95bd6264806c233173b1ec0b", size = 93197 }, + { url = "https://files.pythonhosted.org/packages/dd/3d/b19ab5850a2c35d11cda16d132842314ae15f9bda5fafb93407ea396f74b/watchdog-4.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8fec441f5adcf81dd240a5fe78e3d83767999771630b5ddfc5867827a34fa3d3", size = 82949 }, + { url = "https://files.pythonhosted.org/packages/c9/58/f747fbcb87bec05f499efb5372b95cd6b18fa1df39ec49dc7a02005f8bef/watchdog-4.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:6a9c71a0b02985b4b0b6d14b875a6c86ddea2fdbebd0c9a720a806a8bbffc69f", size = 82950 }, + { url = "https://files.pythonhosted.org/packages/6c/3f/b24f8e098c2a8b6797448e33861990b8e3ef17f37789ec8eee9eccb4ce51/watchdog-4.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:557ba04c816d23ce98a06e70af6abaa0485f6d94994ec78a42b05d1c03dcbd50", size = 82949 }, + { url = "https://files.pythonhosted.org/packages/63/b1/c2d3778b2161e3f9de553ed038de7a565ecc9efcd448550efd4f3df5c5ba/watchdog-4.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:d0f9bd1fd919134d459d8abf954f63886745f4660ef66480b9d753a7c9d40927", size = 82950 }, + { url = "https://files.pythonhosted.org/packages/11/2a/4139eed6a762cfe5cf98e1b0e8485ef5195cff60a02c69d8bbf3ec2ad279/watchdog-4.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:f9b2fdca47dc855516b2d66eef3c39f2672cbf7e7a42e7e67ad2cbfcd6ba107d", size = 82950 }, + { url = "https://files.pythonhosted.org/packages/21/0f/9c5429ae4547ec8c90dc009a5477735a5c3e5975ffc6eb69534d2bdb5365/watchdog-4.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:73c7a935e62033bd5e8f0da33a4dcb763da2361921a69a5a95aaf6c93aa03a87", size = 82947 }, + { url = "https://files.pythonhosted.org/packages/91/7b/26d2f43aa9fe428416be21ee1cb9ac75638cf302466b7e706c14eeaea42c/watchdog-4.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:6a80d5cae8c265842c7419c560b9961561556c4361b297b4c431903f8c33b269", size = 82950 }, + { url = "https://files.pythonhosted.org/packages/9e/7f/6f967900d3ec93bbd4719b38272c0377e67292424fb2e08b5da4c0a33398/watchdog-4.0.0-py3-none-win32.whl", hash = "sha256:8f9a542c979df62098ae9c58b19e03ad3df1c9d8c6895d96c0d51da17b243b1c", size = 82935 }, + { url = "https://files.pythonhosted.org/packages/d2/5c/110884d0c632aedc54cdef5b7de3a78b388b03582e3ba57ae06c79db8b10/watchdog-4.0.0-py3-none-win_amd64.whl", hash = "sha256:f970663fa4f7e80401a7b0cbeec00fa801bf0287d93d48368fc3e6fa32716245", size = 82939 }, + { url = "https://files.pythonhosted.org/packages/6c/b9/269c1e10115a4525feca2e90ccefcf45da22d750c5be74f0dfdb8d2920ee/watchdog-4.0.0-py3-none-win_ia64.whl", hash = "sha256:9a03e16e55465177d416699331b0f3564138f1807ecc5f2de9d55d8f188d08c7", size = 82938 }, +] + +[[package]] +name = "wcwidth" +version = "0.8.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166 }, +] + +[[package]] +name = "websockets" +version = "12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/62/7a7874b7285413c954a4cca3c11fd851f11b2fe5b4ae2d9bee4f6d9bdb10/websockets-12.0.tar.gz", hash = "sha256:81df9cbcbb6c260de1e007e58c011bfebe2dafc8435107b0537f393dd38c8b1b", size = 104994 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/73/9c1e168a2e7fdf26841dc98f5f5502e91dea47428da7690a08101f616169/websockets-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5d873c7de42dea355d73f170be0f23788cf3fa9f7bed718fd2830eefedce01b4", size = 124047 }, + { url = "https://files.pythonhosted.org/packages/e4/2d/9a683359ad2ed11b2303a7a94800db19c61d33fa3bde271df09e99936022/websockets-12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3f61726cae9f65b872502ff3c1496abc93ffbe31b278455c418492016e2afc8f", size = 121282 }, + { url = "https://files.pythonhosted.org/packages/95/aa/75fa3b893142d6d98a48cb461169bd268141f2da8bfca97392d6462a02eb/websockets-12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed2fcf7a07334c77fc8a230755c2209223a7cc44fc27597729b8ef5425aa61a3", size = 121325 }, + { url = "https://files.pythonhosted.org/packages/6e/a4/51a25e591d645df71ee0dc3a2c880b28e5514c00ce752f98a40a87abcd1e/websockets-12.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e332c210b14b57904869ca9f9bf4ca32f5427a03eeb625da9b616c85a3a506c", size = 131502 }, + { url = "https://files.pythonhosted.org/packages/cd/ea/0ceeea4f5b87398fe2d9f5bcecfa00a1bcd542e2bfcac2f2e5dd612c4e9e/websockets-12.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5693ef74233122f8ebab026817b1b37fe25c411ecfca084b29bc7d6efc548f45", size = 130491 }, + { url = "https://files.pythonhosted.org/packages/e3/05/f52a60b66d9faf07a4f7d71dc056bffafe36a7e98c4eb5b78f04fe6e4e85/websockets-12.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e9e7db18b4539a29cc5ad8c8b252738a30e2b13f033c2d6e9d0549b45841c04", size = 130872 }, + { url = "https://files.pythonhosted.org/packages/ac/4e/c7361b2d7b964c40fea924d64881145164961fcd6c90b88b7e3ab2c4f431/websockets-12.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6e2df67b8014767d0f785baa98393725739287684b9f8d8a1001eb2839031447", size = 136318 }, + { url = "https://files.pythonhosted.org/packages/0a/31/337bf35ae5faeaf364c9cddec66681cdf51dc4414ee7a20f92a18e57880f/websockets-12.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:bea88d71630c5900690fcb03161ab18f8f244805c59e2e0dc4ffadae0a7ee0ca", size = 135594 }, + { url = "https://files.pythonhosted.org/packages/95/aa/1ac767825c96f9d7e43c4c95683757d4ef28cf11fa47a69aca42428d3e3a/websockets-12.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:dff6cdf35e31d1315790149fee351f9e52978130cef6c87c4b6c9b3baf78bc53", size = 136191 }, + { url = "https://files.pythonhosted.org/packages/28/4b/344ec5cfeb6bc417da097f8253607c3aed11d9a305fb58346f506bf556d8/websockets-12.0-cp311-cp311-win32.whl", hash = "sha256:3e3aa8c468af01d70332a382350ee95f6986db479ce7af14d5e81ec52aa2b402", size = 124453 }, + { url = "https://files.pythonhosted.org/packages/d1/40/6b169cd1957476374f51f4486a3e85003149e62a14e6b78a958c2222337a/websockets-12.0-cp311-cp311-win_amd64.whl", hash = "sha256:25eb766c8ad27da0f79420b2af4b85d29914ba0edf69f547cc4f06ca6f1d403b", size = 124971 }, + { url = "https://files.pythonhosted.org/packages/79/4d/9cc401e7b07e80532ebc8c8e993f42541534da9e9249c59ee0139dcb0352/websockets-12.0-py3-none-any.whl", hash = "sha256:dc284bbc8d7c78a6c69e0c7325ab46ee5e40bb4d50e494d8131a07ef47500e9e", size = 118370 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459 }, +] + +[[package]] +name = "wrapt" +version = "1.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/4c/063a912e20bcef7124e0df97282a8af3ff3e4b603ce84c481d6d7346be0a/wrapt-1.16.0.tar.gz", hash = "sha256:5f370f952971e7d17c7d1ead40e49f32345a7f7a5373571ef44d800d06b1899d", size = 53972 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/03/c188ac517f402775b90d6f312955a5e53b866c964b32119f2ed76315697e/wrapt-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a5db485fe2de4403f13fafdc231b0dbae5eca4359232d2efc79025527375b09", size = 37313 }, + { url = "https://files.pythonhosted.org/packages/0f/16/ea627d7817394db04518f62934a5de59874b587b792300991b3c347ff5e0/wrapt-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:75ea7d0ee2a15733684badb16de6794894ed9c55aa5e9903260922f0482e687d", size = 38164 }, + { url = "https://files.pythonhosted.org/packages/7f/a7/f1212ba098f3de0fd244e2de0f8791ad2539c03bef6c05a9fcb03e45b089/wrapt-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a452f9ca3e3267cd4d0fcf2edd0d035b1934ac2bd7e0e57ac91ad6b95c0c6389", size = 80890 }, + { url = "https://files.pythonhosted.org/packages/b7/96/bb5e08b3d6db003c9ab219c487714c13a237ee7dcc572a555eaf1ce7dc82/wrapt-1.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43aa59eadec7890d9958748db829df269f0368521ba6dc68cc172d5d03ed8060", size = 73118 }, + { url = "https://files.pythonhosted.org/packages/6e/52/2da48b35193e39ac53cfb141467d9f259851522d0e8c87153f0ba4205fb1/wrapt-1.16.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72554a23c78a8e7aa02abbd699d129eead8b147a23c56e08d08dfc29cfdddca1", size = 80746 }, + { url = "https://files.pythonhosted.org/packages/11/fb/18ec40265ab81c0e82a934de04596b6ce972c27ba2592c8b53d5585e6bcd/wrapt-1.16.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d2efee35b4b0a347e0d99d28e884dfd82797852d62fcd7ebdeee26f3ceb72cf3", size = 85668 }, + { url = "https://files.pythonhosted.org/packages/0f/ef/0ecb1fa23145560431b970418dce575cfaec555ab08617d82eb92afc7ccf/wrapt-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:6dcfcffe73710be01d90cae08c3e548d90932d37b39ef83969ae135d36ef3956", size = 78556 }, + { url = "https://files.pythonhosted.org/packages/25/62/cd284b2b747f175b5a96cbd8092b32e7369edab0644c45784871528eb852/wrapt-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:eb6e651000a19c96f452c85132811d25e9264d836951022d6e81df2fff38337d", size = 85712 }, + { url = "https://files.pythonhosted.org/packages/e5/a7/47b7ff74fbadf81b696872d5ba504966591a3468f1bc86bca2f407baef68/wrapt-1.16.0-cp311-cp311-win32.whl", hash = "sha256:66027d667efe95cc4fa945af59f92c5a02c6f5bb6012bff9e60542c74c75c362", size = 35327 }, + { url = "https://files.pythonhosted.org/packages/cf/c3/0084351951d9579ae83a3d9e38c140371e4c6b038136909235079f2e6e78/wrapt-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:aefbc4cb0a54f91af643660a0a150ce2c090d3652cf4052a5397fb2de549cd89", size = 37523 }, + { url = "https://files.pythonhosted.org/packages/ff/21/abdedb4cdf6ff41ebf01a74087740a709e2edb146490e4d9beea054b0b7a/wrapt-1.16.0-py3-none-any.whl", hash = "sha256:6906c4100a8fcbf2fa735f6059214bb13b97f75b1a61777fcf6432121ef12ef1", size = 23362 }, +] + +[[package]] +name = "yarl" +version = "1.18.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/9d/4b94a8e6d2b51b599516a5cb88e5bc99b4d8d4583e468057eaa29d5f0918/yarl-1.18.3.tar.gz", hash = "sha256:ac1801c45cbf77b6c99242eeff4fffb5e4e73a800b5c4ad4fc0be5def634d2e1", size = 181062 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/93/282b5f4898d8e8efaf0790ba6d10e2245d2c9f30e199d1a85cae9356098c/yarl-1.18.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8503ad47387b8ebd39cbbbdf0bf113e17330ffd339ba1144074da24c545f0069", size = 141555 }, + { url = "https://files.pythonhosted.org/packages/6d/9c/0a49af78df099c283ca3444560f10718fadb8a18dc8b3edf8c7bd9fd7d89/yarl-1.18.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:02ddb6756f8f4517a2d5e99d8b2f272488e18dd0bfbc802f31c16c6c20f22193", size = 94351 }, + { url = "https://files.pythonhosted.org/packages/5a/a1/205ab51e148fdcedad189ca8dd587794c6f119882437d04c33c01a75dece/yarl-1.18.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:67a283dd2882ac98cc6318384f565bffc751ab564605959df4752d42483ad889", size = 92286 }, + { url = "https://files.pythonhosted.org/packages/ed/fe/88b690b30f3f59275fb674f5f93ddd4a3ae796c2b62e5bb9ece8a4914b83/yarl-1.18.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d980e0325b6eddc81331d3f4551e2a333999fb176fd153e075c6d1c2530aa8a8", size = 340649 }, + { url = "https://files.pythonhosted.org/packages/07/eb/3b65499b568e01f36e847cebdc8d7ccb51fff716dbda1ae83c3cbb8ca1c9/yarl-1.18.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b643562c12680b01e17239be267bc306bbc6aac1f34f6444d1bded0c5ce438ca", size = 356623 }, + { url = "https://files.pythonhosted.org/packages/33/46/f559dc184280b745fc76ec6b1954de2c55595f0ec0a7614238b9ebf69618/yarl-1.18.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c017a3b6df3a1bd45b9fa49a0f54005e53fbcad16633870104b66fa1a30a29d8", size = 354007 }, + { url = "https://files.pythonhosted.org/packages/af/ba/1865d85212351ad160f19fb99808acf23aab9a0f8ff31c8c9f1b4d671fc9/yarl-1.18.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75674776d96d7b851b6498f17824ba17849d790a44d282929c42dbb77d4f17ae", size = 344145 }, + { url = "https://files.pythonhosted.org/packages/94/cb/5c3e975d77755d7b3d5193e92056b19d83752ea2da7ab394e22260a7b824/yarl-1.18.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ccaa3a4b521b780a7e771cc336a2dba389a0861592bbce09a476190bb0c8b4b3", size = 336133 }, + { url = "https://files.pythonhosted.org/packages/19/89/b77d3fd249ab52a5c40859815765d35c91425b6bb82e7427ab2f78f5ff55/yarl-1.18.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2d06d3005e668744e11ed80812e61efd77d70bb7f03e33c1598c301eea20efbb", size = 347967 }, + { url = "https://files.pythonhosted.org/packages/35/bd/f6b7630ba2cc06c319c3235634c582a6ab014d52311e7d7c22f9518189b5/yarl-1.18.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:9d41beda9dc97ca9ab0b9888cb71f7539124bc05df02c0cff6e5acc5a19dcc6e", size = 346397 }, + { url = "https://files.pythonhosted.org/packages/18/1a/0b4e367d5a72d1f095318344848e93ea70da728118221f84f1bf6c1e39e7/yarl-1.18.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ba23302c0c61a9999784e73809427c9dbedd79f66a13d84ad1b1943802eaaf59", size = 350206 }, + { url = "https://files.pythonhosted.org/packages/b5/cf/320fff4367341fb77809a2d8d7fe75b5d323a8e1b35710aafe41fdbf327b/yarl-1.18.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6748dbf9bfa5ba1afcc7556b71cda0d7ce5f24768043a02a58846e4a443d808d", size = 362089 }, + { url = "https://files.pythonhosted.org/packages/57/cf/aadba261d8b920253204085268bad5e8cdd86b50162fcb1b10c10834885a/yarl-1.18.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0b0cad37311123211dc91eadcb322ef4d4a66008d3e1bdc404808992260e1a0e", size = 366267 }, + { url = "https://files.pythonhosted.org/packages/54/58/fb4cadd81acdee6dafe14abeb258f876e4dd410518099ae9a35c88d8097c/yarl-1.18.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0fb2171a4486bb075316ee754c6d8382ea6eb8b399d4ec62fde2b591f879778a", size = 359141 }, + { url = "https://files.pythonhosted.org/packages/9a/7a/4c571597589da4cd5c14ed2a0b17ac56ec9ee7ee615013f74653169e702d/yarl-1.18.3-cp311-cp311-win32.whl", hash = "sha256:61b1a825a13bef4a5f10b1885245377d3cd0bf87cba068e1d9a88c2ae36880e1", size = 84402 }, + { url = "https://files.pythonhosted.org/packages/ae/7b/8600250b3d89b625f1121d897062f629883c2f45339623b69b1747ec65fa/yarl-1.18.3-cp311-cp311-win_amd64.whl", hash = "sha256:b9d60031cf568c627d028239693fd718025719c02c9f55df0a53e587aab951b5", size = 91030 }, + { url = "https://files.pythonhosted.org/packages/f5/4b/a06e0ec3d155924f77835ed2d167ebd3b211a7b0853da1cf8d8414d784ef/yarl-1.18.3-py3-none-any.whl", hash = "sha256:b57f4f58099328dfb26c6a771d09fb20dbbae81d20cfb66141251ea063bd101b", size = 45109 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238 }, +] diff --git a/integrations/isaac-lab/examples/golden-gate-plate-transfer.binding.toml b/integrations/isaac-lab/examples/golden-gate-plate-transfer.binding.toml new file mode 100644 index 0000000..87b70f8 --- /dev/null +++ b/integrations/isaac-lab/examples/golden-gate-plate-transfer.binding.toml @@ -0,0 +1,43 @@ +format = "lab.isaac-binding.v0" +task_id = "assembly_thermocycle.to-odtc-1" +calibration = "prototype-proxy" +provenance = "Scaled gripper-compatible plate proxy and hand-authored reachable poses; not measured from a real SBS plate, STAR, ODTC, or gripper." + +[robot] +model = "franka-panda" +controller = "relative-ik" + +[object] +shape = "cuboid" +size_m = [0.076, 0.050, 0.0144] +mass_kg = 0.12 +static_friction = 0.45 +dynamic_friction = 0.35 + +# Poses are expressed in the Franka base frame. The prototype maps the real +# station identities onto two reachable locations on one simulated table. +[source] +station = "star-1" +position_m = [0.45, -0.18, 0.043] +quaternion_wxyz = [1.0, 0.0, 0.0, 0.0] +position_jitter_m = [0.015, 0.015, 0.0] + +[destination] +station = "odtc-1" +position_m = [0.45, 0.18, 0.043] +quaternion_wxyz = [1.0, 0.0, 0.0, 0.0] +position_jitter_m = [0.01, 0.01, 0.0] + +[goal] +position_tolerance_m = 0.02 +orientation_tolerance_rad = 0.15 +max_linear_velocity_mps = 0.05 +max_angular_velocity_radps = 0.20 +minimum_gripper_open_m = 0.03 + +[simulation] +dt_seconds = 0.01 +decimation = 2 +episode_length_seconds = 8.0 +num_envs = 256 +env_spacing_m = 2.5 diff --git a/integrations/isaac-lab/pyproject.toml b/integrations/isaac-lab/pyproject.toml new file mode 100644 index 0000000..7fc6ad1 --- /dev/null +++ b/integrations/isaac-lab/pyproject.toml @@ -0,0 +1,39 @@ +[build-system] +requires = ["hatchling>=1.27,<2"] +build-backend = "hatchling.build" + +[project] +name = "lab-isaac" +version = "0.1.0" +description = "Isaac Lab adapter for backend-neutral Lab robot tasks" +requires-python = ">=3.11" +license = "Apache-2.0" + +[project.scripts] +lab-isaac = "lab_isaac.cli:main" + +[dependency-groups] +dev = [ + "mypy>=1.17,<2", + "pytest>=8.4,<9", + "ruff>=0.12,<1", +] + +[tool.ruff] +target-version = "py311" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM", "RUF"] + +[tool.mypy] +python_version = "3.11" +strict = true +files = ["src", "tests", "c3/probe.py"] + +[[tool.mypy.overrides]] +module = ["isaaclab.*", "isaaclab_tasks.*", "torch"] +ignore_missing_imports = true + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/integrations/isaac-lab/src/lab_isaac/__init__.py b/integrations/isaac-lab/src/lab_isaac/__init__.py new file mode 100644 index 0000000..d6bcf9e --- /dev/null +++ b/integrations/isaac-lab/src/lab_isaac/__init__.py @@ -0,0 +1,5 @@ +"""Isaac Lab adapter for Lab robot-task documents.""" + +from .contract import ISAAC_BINDING_FORMAT, ContractError, Prototype, load_prototype + +__all__ = ["ISAAC_BINDING_FORMAT", "ContractError", "Prototype", "load_prototype"] diff --git a/integrations/isaac-lab/src/lab_isaac/cli.py b/integrations/isaac-lab/src/lab_isaac/cli.py new file mode 100644 index 0000000..e41b665 --- /dev/null +++ b/integrations/isaac-lab/src/lab_isaac/cli.py @@ -0,0 +1,73 @@ +"""Command-line checks and the Linux/CUDA Isaac smoke gate.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import NoReturn + +from .contract import ContractError, load_prototype + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="lab-isaac") + commands = parser.add_subparsers(dest="command", required=True) + inspect = commands.add_parser("inspect", help="validate and resolve a task plus binding") + inspect.add_argument("--task", type=Path, required=True) + inspect.add_argument("--binding", type=Path, required=True) + inspect.add_argument("--json", action="store_true") + smoke = commands.add_parser("smoke", help="launch PhysX and step the manager-based RL env") + smoke.add_argument("--task", type=Path, required=True) + smoke.add_argument("--binding", type=Path, required=True) + smoke.add_argument("--num-envs", type=int) + smoke.add_argument("--steps", type=int, default=4) + return parser + + +def _fail(parser: argparse.ArgumentParser, message: str) -> NoReturn: + parser.exit(2, f"error: {message}\n") + + +def main() -> None: + parser = _parser() + arguments = parser.parse_args() + try: + prototype = load_prototype(arguments.task, arguments.binding) + if arguments.command == "inspect": + summary = prototype.summary() + if arguments.json: + print(json.dumps(summary, indent=2)) + else: + print( + f"task '{prototype.task.task_id}': {prototype.task.object_name} " + f"from {prototype.task.source_station} to " + f"{prototype.task.destination_station}\n" + f" robot: {prototype.binding.robot_model} " + f"({prototype.binding.controller})\n" + f" calibration: {prototype.binding.calibration}\n" + f" scene: {prototype.task.scene_path}" + ) + return + if arguments.num_envs is not None and arguments.num_envs <= 0: + _fail(parser, "--num-envs must be positive") + if arguments.steps <= 0: + _fail(parser, "--steps must be positive") + from .isaac_env import run_smoke + + print( + json.dumps( + run_smoke( + prototype, + num_envs=arguments.num_envs, + steps=arguments.steps, + ), + indent=2, + ) + ) + except (ContractError, RuntimeError) as error: + _fail(parser, str(error)) + + +if __name__ == "__main__": + main() diff --git a/integrations/isaac-lab/src/lab_isaac/contract.py b/integrations/isaac-lab/src/lab_isaac/contract.py new file mode 100644 index 0000000..57d4d3e --- /dev/null +++ b/integrations/isaac-lab/src/lab_isaac/contract.py @@ -0,0 +1,410 @@ +"""Checked boundary between Lab workflow intent and an Isaac physics task.""" + +from __future__ import annotations + +import json +import math +import tomllib +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import cast + +ROBOT_TASK_FORMAT = "lab.robot-task.v0" +SCENE_FORMAT = "lab.scene.v0" +WORKCELL_FORMAT = "lab.workcell-run.v0" +ISAAC_BINDING_FORMAT = "lab.isaac-binding.v0" + +JsonObject = dict[str, object] +Vector3 = tuple[float, float, float] +Quaternion = tuple[float, float, float, float] + + +class ContractError(ValueError): + """The semantic task and simulator binding do not agree.""" + + +@dataclass(frozen=True) +class RobotTask: + path: Path + task_id: str + object_name: str + object_node: str + source_station: str + source_node: str + destination_station: str + destination_node: str + plan_path: Path + scene_path: Path + + +@dataclass(frozen=True) +class Pose: + station: str + position_m: Vector3 + quaternion_wxyz: Quaternion + position_jitter_m: Vector3 + + +@dataclass(frozen=True) +class ObjectPhysics: + shape: str + size_m: Vector3 + mass_kg: float + static_friction: float + dynamic_friction: float + + +@dataclass(frozen=True) +class Goal: + position_tolerance_m: float + orientation_tolerance_rad: float + max_linear_velocity_mps: float + max_angular_velocity_radps: float + minimum_gripper_open_m: float + + +@dataclass(frozen=True) +class Simulation: + dt_seconds: float + decimation: int + episode_length_seconds: float + num_envs: int + env_spacing_m: float + + +@dataclass(frozen=True) +class IsaacBinding: + path: Path + task_id: str + calibration: str + provenance: str + robot_model: str + controller: str + object: ObjectPhysics + source: Pose + destination: Pose + goal: Goal + simulation: Simulation + + +@dataclass(frozen=True) +class Prototype: + task: RobotTask + binding: IsaacBinding + + def summary(self) -> JsonObject: + """Return stable JSON-ready resolved configuration.""" + return cast( + JsonObject, + { + "task": self.task.task_id, + "object": self.task.object_name, + "source": self.task.source_station, + "destination": self.task.destination_station, + "plan": str(self.task.plan_path), + "scene": str(self.task.scene_path), + "calibration": self.binding.calibration, + "provenance": self.binding.provenance, + "robot": { + "model": self.binding.robot_model, + "controller": self.binding.controller, + }, + "object_physics": asdict(self.binding.object), + "source_pose": asdict(self.binding.source), + "destination_pose": asdict(self.binding.destination), + "goal": asdict(self.binding.goal), + "simulation": asdict(self.binding.simulation), + }, + ) + + +def _mapping(value: object, context: str) -> JsonObject: + if not isinstance(value, dict) or not all(isinstance(key, str) for key in value): + raise ContractError(f"{context} must be a table/object") + return cast(JsonObject, value) + + +def _string(table: JsonObject, key: str, context: str) -> str: + value = table.get(key) + if not isinstance(value, str) or not value: + raise ContractError(f"{context}.{key} must be a non-empty string") + return value + + +def _number(table: JsonObject, key: str, context: str, *, positive: bool = True) -> float: + value = table.get(key) + if isinstance(value, bool) or not isinstance(value, int | float): + raise ContractError(f"{context}.{key} must be a number") + result = float(value) + if not math.isfinite(result) or (positive and result <= 0.0): + qualifier = "positive and finite" if positive else "finite" + raise ContractError(f"{context}.{key} must be {qualifier}") + return result + + +def _integer(table: JsonObject, key: str, context: str) -> int: + value = table.get(key) + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + raise ContractError(f"{context}.{key} must be a positive integer") + return value + + +def _vector(table: JsonObject, key: str, context: str, length: int) -> tuple[float, ...]: + value = table.get(key) + if not isinstance(value, list) or len(value) != length: + raise ContractError(f"{context}.{key} must contain exactly {length} numbers") + result: list[float] = [] + for component in value: + if isinstance(component, bool) or not isinstance(component, int | float): + raise ContractError(f"{context}.{key} must contain only numbers") + number = float(component) + if not math.isfinite(number): + raise ContractError(f"{context}.{key} must contain only finite numbers") + result.append(number) + return tuple(result) + + +def _vector3(table: JsonObject, key: str, context: str) -> Vector3: + return cast(Vector3, _vector(table, key, context, 3)) + + +def _quaternion(table: JsonObject, key: str, context: str) -> Quaternion: + result = cast(Quaternion, _vector(table, key, context, 4)) + norm = math.sqrt(sum(component * component for component in result)) + if not math.isclose(norm, 1.0, rel_tol=0.0, abs_tol=1.0e-3): + raise ContractError(f"{context}.{key} must be a normalized WXYZ quaternion") + return result + + +def _string_list(table: JsonObject, key: str, context: str) -> list[str]: + value = table.get(key) + if not isinstance(value, list) or not all(isinstance(item, str) for item in value): + raise ContractError(f"{context}.{key} must be a list of strings") + return cast(list[str], value) + + +def _read_json(path: Path) -> JsonObject: + try: + return _mapping(json.loads(path.read_text()), str(path)) + except OSError as error: + raise ContractError(f"cannot read {path}: {error}") from error + except json.JSONDecodeError as error: + raise ContractError(f"{path} is not valid JSON: {error}") from error + + +def _read_toml(path: Path) -> JsonObject: + try: + return _mapping(tomllib.loads(path.read_text()), str(path)) + except OSError as error: + raise ContractError(f"cannot read {path}: {error}") from error + except tomllib.TOMLDecodeError as error: + raise ContractError(f"{path} is not valid TOML: {error}") from error + + +def _scene_nodes(node: JsonObject, found: dict[str, list[str]]) -> None: + node_id = _string(node, "id", "scene node") + semantic = _mapping(node.get("semantic"), f"scene node '{node_id}'.semantic") + kind = _string(semantic, "kind", f"scene node '{node_id}'.semantic") + found.setdefault(node_id, []).append(kind) + children = node.get("children", []) + if not isinstance(children, list): + raise ContractError(f"scene node '{node_id}'.children must be a list") + for child in children: + _scene_nodes(_mapping(child, f"child of scene node '{node_id}'"), found) + + +def _require_scene_node(found: dict[str, list[str]], node_id: str, kind: str) -> None: + kinds = found.get(node_id, []) + matches = sum(candidate == kind for candidate in kinds) + if not kinds: + raise ContractError(f"semantic scene has no node '{node_id}'") + if matches == 0: + raise ContractError(f"semantic scene node '{node_id}' is not kind '{kind}'") + if matches > 1: + raise ContractError(f"semantic scene has {matches} '{kind}' nodes named '{node_id}'") + + +def _load_task(path: Path) -> RobotTask: + document = _read_json(path) + if _string(document, "format", "robot task") != ROBOT_TASK_FORMAT: + raise ContractError(f"{path} is not a {ROBOT_TASK_FORMAT} document") + if _string(document, "action", "robot task") != "transfer": + raise ContractError("the Isaac prototype currently supports transfer tasks only") + + task_id = _string(document, "id", "robot task") + object_ref = _mapping(document.get("object"), "robot task.object") + source = _mapping(document.get("source"), "robot task.source") + destination = _mapping(document.get("destination"), "robot task.destination") + completion = _mapping(document.get("completion"), "robot task.completion") + object_name = _string(object_ref, "labware", "robot task.object") + source_station = _string(source, "station", "robot task.source") + destination_station = _string(destination, "station", "robot task.destination") + + plan_ref = Path(_string(document, "plan", "robot task")) + plan_path = plan_ref if plan_ref.is_absolute() else path.parent / plan_ref + plan = _read_json(plan_path) + if _string(plan, "format", "workcell plan") != WORKCELL_FORMAT: + raise ContractError(f"{plan_path} is not a {WORKCELL_FORMAT} document") + nodes = plan.get("nodes") + if not isinstance(nodes, list): + raise ContractError("workcell plan.nodes must be a list") + matching_nodes = [ + _mapping(node, "workcell plan node") + for node in nodes + if isinstance(node, dict) and node.get("id") == task_id + ] + if len(matching_nodes) != 1: + raise ContractError( + f"workcell plan must contain exactly one source node '{task_id}'; " + f"found {len(matching_nodes)}" + ) + plan_node = matching_nodes[0] + if _string(plan_node, "action", f"workcell node '{task_id}'") != "handoff": + raise ContractError(f"workcell node '{task_id}' is not a handoff") + plan_relation = ( + _string(plan_node, "from", f"workcell node '{task_id}'"), + _string(plan_node, "to", f"workcell node '{task_id}'"), + _string(plan_node, "labware", f"workcell node '{task_id}'"), + _string(plan_node, "instructions", f"workcell node '{task_id}'"), + _string_list(plan_node, "after", f"workcell node '{task_id}'"), + ) + task_relation = ( + source_station, + destination_station, + object_name, + _string(document, "instructions", "robot task"), + _string_list(document, "after", "robot task"), + ) + if plan_relation != task_relation: + raise ContractError(f"robot task '{task_id}' does not match its source workcell node") + if ( + _string(completion, "relation", "robot task.completion") != "object-at-station" + or _string(completion, "object", "robot task.completion") != object_name + or _string(completion, "target", "robot task.completion") != destination_station + ): + raise ContractError(f"robot task '{task_id}' has an inconsistent completion relation") + + scene_ref = Path(_string(document, "scene", "robot task")) + scene_path = scene_ref if scene_ref.is_absolute() else path.parent / scene_ref + scene = _read_json(scene_path) + if _string(scene, "format", "semantic scene") != SCENE_FORMAT: + raise ContractError(f"{scene_path} is not a {SCENE_FORMAT} document") + found: dict[str, list[str]] = {} + _scene_nodes(_mapping(scene.get("root"), "semantic scene.root"), found) + + object_node = _string(object_ref, "scene_node", "robot task.object") + source_node = _string(source, "scene_node", "robot task.source") + destination_node = _string(destination, "scene_node", "robot task.destination") + _require_scene_node(found, object_node, "labware") + _require_scene_node(found, source_node, "station") + _require_scene_node(found, destination_node, "station") + return RobotTask( + path=path, + task_id=task_id, + object_name=object_name, + object_node=object_node, + source_station=source_station, + source_node=source_node, + destination_station=destination_station, + destination_node=destination_node, + plan_path=plan_path, + scene_path=scene_path, + ) + + +def _pose(table: JsonObject, context: str) -> Pose: + return Pose( + station=_string(table, "station", context), + position_m=_vector3(table, "position_m", context), + quaternion_wxyz=_quaternion(table, "quaternion_wxyz", context), + position_jitter_m=_vector3(table, "position_jitter_m", context), + ) + + +def _load_binding(path: Path) -> IsaacBinding: + document = _read_toml(path) + if _string(document, "format", "Isaac binding") != ISAAC_BINDING_FORMAT: + raise ContractError(f"{path} is not a {ISAAC_BINDING_FORMAT} document") + robot = _mapping(document.get("robot"), "Isaac binding.robot") + object_table = _mapping(document.get("object"), "Isaac binding.object") + goal_table = _mapping(document.get("goal"), "Isaac binding.goal") + simulation = _mapping(document.get("simulation"), "Isaac binding.simulation") + + shape = _string(object_table, "shape", "Isaac binding.object") + if shape != "cuboid": + raise ContractError("the prototype currently supports object.shape = 'cuboid' only") + size = _vector3(object_table, "size_m", "Isaac binding.object") + if any(component <= 0.0 for component in size): + raise ContractError("Isaac binding.object.size_m components must be positive") + robot_model = _string(robot, "model", "Isaac binding.robot") + controller = _string(robot, "controller", "Isaac binding.robot") + if (robot_model, controller) != ("franka-panda", "relative-ik"): + raise ContractError( + "the prototype currently supports robot model 'franka-panda' " + "with controller 'relative-ik' only" + ) + return IsaacBinding( + path=path, + task_id=_string(document, "task_id", "Isaac binding"), + calibration=_string(document, "calibration", "Isaac binding"), + provenance=_string(document, "provenance", "Isaac binding"), + robot_model=robot_model, + controller=controller, + object=ObjectPhysics( + shape=shape, + size_m=size, + mass_kg=_number(object_table, "mass_kg", "Isaac binding.object"), + static_friction=_number(object_table, "static_friction", "Isaac binding.object"), + dynamic_friction=_number(object_table, "dynamic_friction", "Isaac binding.object"), + ), + source=_pose(_mapping(document.get("source"), "Isaac binding.source"), "source"), + destination=_pose( + _mapping(document.get("destination"), "Isaac binding.destination"), + "destination", + ), + goal=Goal( + position_tolerance_m=_number(goal_table, "position_tolerance_m", "Isaac binding.goal"), + orientation_tolerance_rad=_number( + goal_table, "orientation_tolerance_rad", "Isaac binding.goal" + ), + max_linear_velocity_mps=_number( + goal_table, "max_linear_velocity_mps", "Isaac binding.goal" + ), + max_angular_velocity_radps=_number( + goal_table, "max_angular_velocity_radps", "Isaac binding.goal" + ), + minimum_gripper_open_m=_number( + goal_table, "minimum_gripper_open_m", "Isaac binding.goal" + ), + ), + simulation=Simulation( + dt_seconds=_number(simulation, "dt_seconds", "Isaac binding.simulation"), + decimation=_integer(simulation, "decimation", "Isaac binding.simulation"), + episode_length_seconds=_number( + simulation, "episode_length_seconds", "Isaac binding.simulation" + ), + num_envs=_integer(simulation, "num_envs", "Isaac binding.simulation"), + env_spacing_m=_number(simulation, "env_spacing_m", "Isaac binding.simulation"), + ), + ) + + +def load_prototype(task_path: Path, binding_path: Path) -> Prototype: + """Load and cross-check the semantic task, scene, and Isaac binding.""" + task = _load_task(task_path) + binding = _load_binding(binding_path) + if binding.task_id != task.task_id: + raise ContractError( + f"binding task_id '{binding.task_id}' does not match task '{task.task_id}'" + ) + if binding.source.station != task.source_station: + raise ContractError( + f"binding source station '{binding.source.station}' does not match " + f"task source '{task.source_station}'" + ) + if binding.destination.station != task.destination_station: + raise ContractError( + f"binding destination station '{binding.destination.station}' does not match " + f"task destination '{task.destination_station}'" + ) + return Prototype(task=task, binding=binding) diff --git a/integrations/isaac-lab/src/lab_isaac/isaac_env.py b/integrations/isaac-lab/src/lab_isaac/isaac_env.py new file mode 100644 index 0000000..be5a49b --- /dev/null +++ b/integrations/isaac-lab/src/lab_isaac/isaac_env.py @@ -0,0 +1,213 @@ +"""Isaac Lab environment construction, imported only inside Isaac Python.""" + +from __future__ import annotations + +import math +from typing import Any + +from .contract import Prototype, Quaternion + + +def _quaternion_to_rpy(quaternion: Quaternion) -> tuple[float, float, float]: + w, x, y, z = quaternion + sin_roll_cos_pitch = 2.0 * (w * x + y * z) + cos_roll_cos_pitch = 1.0 - 2.0 * (x * x + y * y) + roll = math.atan2(sin_roll_cos_pitch, cos_roll_cos_pitch) + sin_pitch = 2.0 * (w * y - z * x) + pitch = ( + math.copysign(math.pi / 2.0, sin_pitch) if abs(sin_pitch) >= 1.0 else math.asin(sin_pitch) + ) + sin_yaw_cos_pitch = 2.0 * (w * z + x * y) + cos_yaw_cos_pitch = 1.0 - 2.0 * (y * y + z * z) + yaw = math.atan2(sin_yaw_cos_pitch, cos_yaw_cos_pitch) + return roll, pitch, yaw + + +def object_reached_goal( + env: Any, + position_tolerance_m: float, + orientation_tolerance_rad: float, + max_linear_velocity_mps: float, + max_angular_velocity_radps: float, + minimum_gripper_open_m: float, + robot_cfg: Any, +) -> Any: + """Require the transferred object to be released at rest at the goal.""" + import torch + from isaaclab.utils.math import combine_frame_transforms, quat_error_magnitude + + command = env.command_manager.get_command("object_pose") + robot = env.scene[robot_cfg.name] + object_asset = env.scene["object"] + goal_position_w, goal_quaternion_w = combine_frame_transforms( + robot.data.root_pos_w, + robot.data.root_quat_w, + command[:, :3], + command[:, 3:7], + ) + position_error = torch.linalg.vector_norm(goal_position_w - object_asset.data.root_pos_w, dim=1) + orientation_error = quat_error_magnitude(goal_quaternion_w, object_asset.data.root_quat_w) + linear_speed = torch.linalg.vector_norm(object_asset.data.root_lin_vel_w, dim=1) + angular_speed = torch.linalg.vector_norm(object_asset.data.root_ang_vel_w, dim=1) + gripper_released = torch.all( + robot.data.joint_pos[:, robot_cfg.joint_ids] > minimum_gripper_open_m, + dim=1, + ) + return ( + (position_error < position_tolerance_m) + & (orientation_error < orientation_tolerance_rad) + & (linear_speed < max_linear_velocity_mps) + & (angular_speed < max_angular_velocity_radps) + & gripper_released + ) + + +def object_goal_success_reward(env: Any, **params: Any) -> Any: + """Sparse completion reward paired exactly with the terminal predicate.""" + return object_reached_goal(env, **params).float() + + +def build_env_cfg(prototype: Prototype, *, num_envs: int | None = None) -> Any: + """Adapt Isaac Lab's Franka relative-IK lift task to the transfer contract.""" + try: + import isaaclab.sim as sim_utils + from isaaclab.managers import RewardTermCfg, SceneEntityCfg, TerminationTermCfg + from isaaclab_tasks.manager_based.manipulation.lift.config.franka.ik_rel_env_cfg import ( + FrankaCubeLiftEnvCfg, + ) + except ModuleNotFoundError as error: + raise RuntimeError( + "Isaac Lab is unavailable; run this command inside an Isaac Lab Python environment" + ) from error + + binding = prototype.binding + cfg = FrankaCubeLiftEnvCfg() + cfg.scene.num_envs = num_envs or binding.simulation.num_envs + cfg.scene.env_spacing = binding.simulation.env_spacing_m + cfg.sim.dt = binding.simulation.dt_seconds + cfg.decimation = binding.simulation.decimation + cfg.sim.render_interval = binding.simulation.decimation + cfg.episode_length_s = binding.simulation.episode_length_seconds + + physics = binding.object + cfg.scene.object.spawn = sim_utils.CuboidCfg( + size=physics.size_m, + rigid_props=sim_utils.RigidBodyPropertiesCfg( + solver_position_iteration_count=16, + solver_velocity_iteration_count=1, + max_depenetration_velocity=5.0, + ), + mass_props=sim_utils.MassPropertiesCfg(mass=physics.mass_kg), + collision_props=sim_utils.CollisionPropertiesCfg(), + physics_material=sim_utils.RigidBodyMaterialCfg( + static_friction=physics.static_friction, + dynamic_friction=physics.dynamic_friction, + ), + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.82, 0.76, 0.30)), + ) + cfg.scene.object.init_state.pos = binding.source.position_m + cfg.scene.object.init_state.rot = binding.source.quaternion_wxyz + + source = binding.source + jitter = source.position_jitter_m + pose_range = cfg.events.reset_object_position.params["pose_range"] + pose_range["x"] = (-jitter[0], jitter[0]) + pose_range["y"] = (-jitter[1], jitter[1]) + pose_range["z"] = (-jitter[2], jitter[2]) + + destination = binding.destination + destination_jitter = destination.position_jitter_m + roll, pitch, yaw = _quaternion_to_rpy(destination.quaternion_wxyz) + ranges = cfg.commands.object_pose.ranges + ranges.pos_x = ( + destination.position_m[0] - destination_jitter[0], + destination.position_m[0] + destination_jitter[0], + ) + ranges.pos_y = ( + destination.position_m[1] - destination_jitter[1], + destination.position_m[1] + destination_jitter[1], + ) + ranges.pos_z = ( + destination.position_m[2] - destination_jitter[2], + destination.position_m[2] + destination_jitter[2], + ) + ranges.roll = (roll, roll) + ranges.pitch = (pitch, pitch) + ranges.yaw = (yaw, yaw) + cfg.commands.object_pose.resampling_time_range = ( + binding.simulation.episode_length_seconds, + binding.simulation.episode_length_seconds, + ) + + # The stock lift task gates goal rewards on a cube being lifted. The + # transfer endpoint is the table, so keep the lift incentive but allow + # goal tracking down to the plate's settled center height. + settled_center_height = min(source.position_m[2], destination.position_m[2]) + cfg.rewards.lifting_object.params["minimal_height"] = settled_center_height + 0.05 + minimum_goal_height = max(0.001, settled_center_height - 0.005) + cfg.rewards.object_goal_tracking.params["minimal_height"] = minimum_goal_height + cfg.rewards.object_goal_tracking_fine_grained.params["minimal_height"] = minimum_goal_height + success_params = { + "position_tolerance_m": binding.goal.position_tolerance_m, + "orientation_tolerance_rad": binding.goal.orientation_tolerance_rad, + "max_linear_velocity_mps": binding.goal.max_linear_velocity_mps, + "max_angular_velocity_radps": binding.goal.max_angular_velocity_radps, + "minimum_gripper_open_m": binding.goal.minimum_gripper_open_m, + } + cfg.rewards.task_success = RewardTermCfg( + func=object_goal_success_reward, + params={ + **success_params, + "robot_cfg": SceneEntityCfg("robot", joint_names=["panda_finger_joint.*"]), + }, + weight=100.0, + ) + cfg.terminations.task_success = TerminationTermCfg( + func=object_reached_goal, + params={ + **success_params, + "robot_cfg": SceneEntityCfg("robot", joint_names=["panda_finger_joint.*"]), + }, + ) + return cfg + + +def run_smoke(prototype: Prototype, *, num_envs: int | None, steps: int) -> JsonObject: + """Launch PhysX, reset parallel environments, and take policy-shaped steps.""" + try: + import torch + from isaaclab.app import AppLauncher + except ModuleNotFoundError as error: + raise RuntimeError( + "Isaac Lab is unavailable; run this command inside an Isaac Lab Python environment" + ) from error + + launcher = AppLauncher(headless=True) + simulation_app = launcher.app + environment: Any | None = None + try: + from isaaclab.envs import ManagerBasedRLEnv + + cfg = build_env_cfg(prototype, num_envs=num_envs) + environment = ManagerBasedRLEnv(cfg=cfg) + environment.reset() + action = torch.zeros( + (environment.num_envs, environment.action_manager.total_action_dim), + device=environment.device, + ) + for _ in range(steps): + environment.step(action) + return { + "status": "isaac-smoke-passed", + "task": prototype.task.task_id, + "environments": environment.num_envs, + "action_dimensions": environment.action_manager.total_action_dim, + "steps": steps, + } + finally: + if environment is not None: + environment.close() + simulation_app.close() + + +JsonObject = dict[str, object] diff --git a/integrations/isaac-lab/src/lab_isaac/py.typed b/integrations/isaac-lab/src/lab_isaac/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/integrations/isaac-lab/src/lab_isaac/py.typed @@ -0,0 +1 @@ + diff --git a/integrations/isaac-lab/tests/fixtures/plan.workcell.json b/integrations/isaac-lab/tests/fixtures/plan.workcell.json new file mode 100644 index 0000000..8678a47 --- /dev/null +++ b/integrations/isaac-lab/tests/fixtures/plan.workcell.json @@ -0,0 +1,26 @@ +{ + "format": "lab.workcell-run.v0", + "stations": [ + { + "name": "star-1", + "kind": "hamilton.star", + "program_dir": "stations/star-1" + }, + { + "name": "odtc-1", + "kind": "inheco.odtc", + "program_dir": "stations/odtc-1" + } + ], + "nodes": [ + { + "id": "assembly_thermocycle.to-odtc-1", + "after": ["assembly_run"], + "action": "handoff", + "from": "star-1", + "to": "odtc-1", + "labware": "reaction_plate", + "instructions": "Seal and transfer the reaction plate." + } + ] +} diff --git a/integrations/isaac-lab/tests/fixtures/robot-tasks/task.json b/integrations/isaac-lab/tests/fixtures/robot-tasks/task.json new file mode 100644 index 0000000..2fbd26a --- /dev/null +++ b/integrations/isaac-lab/tests/fixtures/robot-tasks/task.json @@ -0,0 +1,26 @@ +{ + "format": "lab.robot-task.v0", + "id": "assembly_thermocycle.to-odtc-1", + "plan": "../plan.workcell.json", + "scene": "../scene.json", + "after": ["assembly_run"], + "action": "transfer", + "object": { + "labware": "reaction_plate", + "scene_node": "reaction_plate" + }, + "source": { + "station": "star-1", + "scene_node": "star-1" + }, + "destination": { + "station": "odtc-1", + "scene_node": "odtc-1" + }, + "instructions": "Seal and transfer the reaction plate.", + "completion": { + "relation": "object-at-station", + "object": "reaction_plate", + "target": "odtc-1" + } +} diff --git a/integrations/isaac-lab/tests/fixtures/scene.json b/integrations/isaac-lab/tests/fixtures/scene.json new file mode 100644 index 0000000..151bf6b --- /dev/null +++ b/integrations/isaac-lab/tests/fixtures/scene.json @@ -0,0 +1,28 @@ +{ + "format": "lab.scene.v0", + "name": "test-workcell", + "root": { + "id": "workcell", + "semantic": { "kind": "room" }, + "translation": [0.0, 0.0, 0.0], + "children": [ + { + "id": "star-1", + "semantic": { "kind": "station", "station_kind": "hamilton.star" }, + "translation": [0.0, 0.0, 0.0], + "children": [ + { + "id": "reaction_plate", + "semantic": { "kind": "labware", "catalog": "corning-96" }, + "translation": [0.0, 0.0, 0.0] + } + ] + }, + { + "id": "odtc-1", + "semantic": { "kind": "station", "station_kind": "inheco.odtc" }, + "translation": [1000.0, 0.0, 0.0] + } + ] + } +} diff --git a/integrations/isaac-lab/tests/test_contract.py b/integrations/isaac-lab/tests/test_contract.py new file mode 100644 index 0000000..5bc8803 --- /dev/null +++ b/integrations/isaac-lab/tests/test_contract.py @@ -0,0 +1,56 @@ +from pathlib import Path + +import pytest + +from lab_isaac import ContractError, load_prototype + +FIXTURES = Path(__file__).parent / "fixtures" +TASK = FIXTURES / "robot-tasks" / "task.json" +BINDING = Path(__file__).parent.parent / "examples" / "golden-gate-plate-transfer.binding.toml" + + +def test_golden_gate_transfer_contract_resolves() -> None: + prototype = load_prototype(TASK, BINDING) + + assert prototype.task.object_name == "reaction_plate" + assert prototype.task.source_station == "star-1" + assert prototype.task.destination_station == "odtc-1" + assert prototype.task.plan_path.name == "plan.workcell.json" + assert prototype.binding.robot_model == "franka-panda" + assert prototype.binding.object.size_m == (0.076, 0.050, 0.0144) + assert prototype.summary()["calibration"] == "prototype-proxy" + + +def test_binding_and_task_station_must_agree(tmp_path: Path) -> None: + text = BINDING.read_text().replace('station = "odtc-1"', 'station = "reader-1"', 1) + binding = tmp_path / "mismatch.toml" + binding.write_text(text) + + with pytest.raises(ContractError, match="destination station 'reader-1'"): + load_prototype(TASK, binding) + + +def test_task_nodes_must_exist_in_the_semantic_scene(tmp_path: Path) -> None: + tasks = tmp_path / "robot-tasks" + tasks.mkdir() + task = tasks / "task.json" + plan = tmp_path / "plan.workcell.json" + scene = tmp_path / "scene.json" + task.write_text(TASK.read_text()) + plan.write_text((FIXTURES / "plan.workcell.json").read_text()) + scene.write_text((FIXTURES / "scene.json").read_text().replace('"id": "odtc-1"', '"id": "x"')) + + with pytest.raises(ContractError, match="no node 'odtc-1'"): + load_prototype(task, BINDING) + + +def test_projected_task_must_still_match_its_source_plan(tmp_path: Path) -> None: + tasks = tmp_path / "robot-tasks" + tasks.mkdir() + task = tasks / "task.json" + task.write_text(TASK.read_text().replace("Seal and transfer", "Discard")) + (tmp_path / "plan.workcell.json").write_text((FIXTURES / "plan.workcell.json").read_text()) + (tmp_path / "scene.json").write_text((FIXTURES / "scene.json").read_text()) + + with pytest.raises(ContractError, match="does not match its source workcell node"): + load_prototype(task, BINDING) diff --git a/integrations/isaac-lab/tests/test_isaac_env.py b/integrations/isaac-lab/tests/test_isaac_env.py new file mode 100644 index 0000000..3b0ff37 --- /dev/null +++ b/integrations/isaac-lab/tests/test_isaac_env.py @@ -0,0 +1,15 @@ +import math + +import pytest + +from lab_isaac.isaac_env import _quaternion_to_rpy + + +def test_wxyz_quaternion_becomes_isaac_command_euler_angles() -> None: + half_turn = math.sqrt(0.5) + + roll, pitch, yaw = _quaternion_to_rpy((half_turn, 0.0, 0.0, half_turn)) + + assert roll == pytest.approx(0.0) + assert pitch == pytest.approx(0.0) + assert yaw == pytest.approx(math.pi / 2.0) diff --git a/integrations/isaac-lab/uv.lock b/integrations/isaac-lab/uv.lock new file mode 100644 index 0000000..107af17 --- /dev/null +++ b/integrations/isaac-lab/uv.lock @@ -0,0 +1,307 @@ +version = 1 +revision = 1 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 }, +] + +[[package]] +name = "lab-isaac" +version = "0.1.0" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=1.17,<2" }, + { name = "pytest", specifier = ">=8.4,<9" }, + { name = "ruff", specifier = ">=0.12,<1" }, +] + +[[package]] +name = "librt" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/52/06790ced2ac7117f890c21bda43c39c958ec82aa665c0718e821d33ff939/librt-0.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:823b92cf3c18ecd08afc70c42473888b41b6e8ef5046f3b82c05c154a2fa3d22", size = 148039 }, + { url = "https://files.pythonhosted.org/packages/e7/1d/8e150b7fc449a1f33c8a760965cc1f43b14fc1577d9d0b50ab2701420e74/librt-0.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c70bc1b602cf59917e8f0c7a2cbc8bcc6fbc14d5486136b00707a79619121d63", size = 153067 }, + { url = "https://files.pythonhosted.org/packages/51/87/a162bc5a66a35599dc619ecb215145f4de7d68e886b479b6d12593139f7c/librt-0.15.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:814ff83a25b5fce8b9c80c4dd803153fb5c5599fc74db9e022466938368957ef", size = 493087 }, + { url = "https://files.pythonhosted.org/packages/e5/3a/aeea1fc620cf48060d3065b37614edbf97043c099d0f50782bc8ca61d897/librt-0.15.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:57f5eeb6ad4c180de583b1038e61fe5fbd9796bb69a8a1c1a0c7ddbec4c8c60f", size = 485608 }, + { url = "https://files.pythonhosted.org/packages/52/ff/fe571ad416f0856fd0d5578ffc2e6dc531891e586e36b647bcf50569cab8/librt-0.15.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82909c8f7eb9952656b65d3147afde4cf8e6d5a991eebc86418b5e65843b0ab8", size = 498723 }, + { url = "https://files.pythonhosted.org/packages/0f/e1/7a65eb5dedb1f00aebd948cdd8e17add48bf066cab3514e9daf84ab45a6c/librt-0.15.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f779070399f991400fc451719e0ea388eb7de313388bada2c127a35de05f798a", size = 516002 }, + { url = "https://files.pythonhosted.org/packages/5f/45/59832b0ebfbd08c2742e6ece372ceb53f18bf1faef5d33c8daf3abebf749/librt-0.15.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bac89069bc496ebdf4f79ebb57bbd10d0b214c8454225deb672d91002bd17e18", size = 508607 }, + { url = "https://files.pythonhosted.org/packages/ea/0d/37fa73f3b43ebd8259f91ae9102a15e5a54e65d581e48dea72df3e81d7a4/librt-0.15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e0d00c708fb2f5822b152429b1ac80a58dbbbc3f6c232c4d13a3f7fcf2ea5b4c", size = 530422 }, + { url = "https://files.pythonhosted.org/packages/26/02/e046c6fe7a5881ac34623242192f484426ba8a75595fd18f22c53a3f530f/librt-0.15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6c6624fe268625869485553dd7cc1daf30d22558215bb2a4ff16f67a9801a31a", size = 534303 }, + { url = "https://files.pythonhosted.org/packages/95/32/d5e6d861ab0366f3edf74f887ab0c9eb9f535aaf01d32b80b4f734daa179/librt-0.15.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f56b397858a23dacf35ede366ed2212fdc03a6a57a1ad36468ad6e9dc5fac091", size = 536084 }, + { url = "https://files.pythonhosted.org/packages/2a/de/d69d725513fe53fc90c6d7a1f86e4428939bad2fb905b17fe4c18d413dde/librt-0.15.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4388184646efe2054911c5b00a1077d6d1ee86a95b7e8ba96dc7850a809f3f40", size = 514307 }, + { url = "https://files.pythonhosted.org/packages/36/93/f8aded0d6682b4f25820fa86e0690f87f01df9fd7bd09ddb04d9167ad021/librt-0.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:97335f59082f9fe2ce6c2a9cc6433a0114bbb6cd4d5c09dd76c95c68b9f9a8b0", size = 557686 }, + { url = "https://files.pythonhosted.org/packages/74/09/ffeb6bdeb6cd862b4272fddc8ad05f938dd25d020ed517e631813917d80a/librt-0.15.0-cp311-cp311-win32.whl", hash = "sha256:83380ffde38062a2e9bb55d83e74474f6614665528b98a6928720fc006dfffbb", size = 104917 }, + { url = "https://files.pythonhosted.org/packages/96/28/7e2313a3ffbf0b4de7ba3da58a09e488507b4bd1ea2b5e69378354a23415/librt-0.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:f75720477ee05d509a310e856cacc8d909adc182f7b91193c207bcc26d7ee6db", size = 125886 }, + { url = "https://files.pythonhosted.org/packages/39/9e/04b8c3cde014ef255ee785730425268354543acc38902093a40afa0dc164/librt-0.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:256237037a3ab001ae8d9803b2d43562a4c3aa38739843694349e4d5ebb0fd56", size = 111885 }, + { url = "https://files.pythonhosted.org/packages/ba/39/99c25030e782bdfb7a21be8c05254806a2e4bbb05c8d50c2a2130acbfa05/librt-0.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e87bc679f86a99aa3b26e3c78eeb821a247c9a28eae48eaafcc32c3bf4c3bb9e", size = 151021 }, + { url = "https://files.pythonhosted.org/packages/14/43/f4b1bd1b2888798a1409808889a25ea1ba49eaabce7d681ed27734c2df9d/librt-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71599e011ac880e8e45d46047d714871894c7d4ab6f25626f8d4f89da21f368d", size = 155267 }, + { url = "https://files.pythonhosted.org/packages/0c/db/3ad9c965c72f1e1d6beeec44ec10a54e17be8ae042fbb4baade16cbadced/librt-0.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c802434092b769b1d613ed2e13fac15fbfce1934a74bd10283b03c0fae231cd1", size = 503136 }, + { url = "https://files.pythonhosted.org/packages/4b/07/5888a6d76acd62ebce66c61b74d94e9370b9c32929f111e487bb6546f8ed/librt-0.15.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5500eeae393a184d14e1f35645962c27129d20c81afa4069e6ef826ebc2b3aaa", size = 496670 }, + { url = "https://files.pythonhosted.org/packages/29/39/ab57cc2f5b276156da02bb7f5a8921bada1cb1993ffec99acf811c602c23/librt-0.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6ecfc32dfb46fb7b565bcd6abf9412acf978775a998273d22888a6d7953730dd", size = 513688 }, + { url = "https://files.pythonhosted.org/packages/a7/b9/bdbb0b648b5c2befb031f4c6f3b1dd857415e8fb492a25a3c764a6681e6c/librt-0.15.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cc46cfd15022e35084355478c9ac809d90b1152222706ac9a7655ec21df6fa", size = 531904 }, + { url = "https://files.pythonhosted.org/packages/93/26/473c2e4b6c104e9e58e27ce95fc8005c8bd4fc36cae4f254371125a92db8/librt-0.15.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5f51401d102c885b9ca509e62c79b1dbff286e1b9b047fde6f763780789356d", size = 524427 }, + { url = "https://files.pythonhosted.org/packages/26/60/03b3abb82b41714671b907bf6989b228e31e6a8af52dec82b5b0728dc250/librt-0.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cc30523e3f1a23fb7511cc659834a0d01a1042bb9de359bc1c131cc4ec6c9656", size = 543155 }, + { url = "https://files.pythonhosted.org/packages/f2/0e/9bb1f0a4affbd0a1888f4f79dc03ed2a299d9a2c26c59ab2a97dcbf11903/librt-0.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:59fe030d8ae4a57e3fb7756bf35a858de74e04066fc8555c53d0af979132af81", size = 546890 }, + { url = "https://files.pythonhosted.org/packages/dc/84/6937a280d461f7de6e031ffb02edc2b7c3c90d49d630565ce8ff27cbc5f2/librt-0.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5a6526a2a956bbb1e4ae3568c82e650fc99119c66bb011ea60715744955a2b4d", size = 555163 }, + { url = "https://files.pythonhosted.org/packages/bc/95/2a2853c1ee014bf102116e7f897a04beeaeb2461b45b79af98bdfb95f1ef/librt-0.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:85ea21ec6730194d67156b0e0b5430ccb1d61f8b8b907e39b37f9812b74a13f0", size = 535812 }, + { url = "https://files.pythonhosted.org/packages/c9/4c/cf9601c1b4c5f09280acd5d83abdb2e68527a2be8257136eb42304218622/librt-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1e47b8ba865d7ede071a91a7163073bbaeb72541f1ef8a07d512c45c7b5007f2", size = 573688 }, + { url = "https://files.pythonhosted.org/packages/47/6d/9ac7cbec46189a7625af4b5acbd25f10d827f4141b2002181848c8418923/librt-0.15.0-cp312-cp312-win32.whl", hash = "sha256:a5207ec414d1c4a2a7231b2086970dc036f94293cdf338190984958a013a42f1", size = 106138 }, + { url = "https://files.pythonhosted.org/packages/38/d0/2ae99c83be86ce23f925ac1aeeedc777e97f427c4a8d190c70d0a16e9a87/librt-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:73b30cfa976659b3917c8f6153bdb0591c6a9ec6583599fd24a689b690622022", size = 126974 }, + { url = "https://files.pythonhosted.org/packages/5d/ef/dd24f9635c730b86b87587967dda7516b1845e8b17684603d31607fed598/librt-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:a54cf9e0ef47b96af580849db5471142200568ce1e02cbf416addab551369570", size = 112292 }, + { url = "https://files.pythonhosted.org/packages/e7/42/467b53a601b406ccd7b97c1fd54b59cb34f9185ad5ce7e9d5c3c4e8961c8/librt-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:db13ca398005abcbe538deda87b686d9bd08b7001cf40c4c06b444960ae10a26", size = 151029 }, + { url = "https://files.pythonhosted.org/packages/3e/e6/36c2299b7a94b84fdd01220d8a777a71be5be0925bb0dbdf71c0a06a34d9/librt-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa1f1995789dca3698bc550aaceb09a51bd5df0a057ff84ff15296cd1975b801", size = 155194 }, + { url = "https://files.pythonhosted.org/packages/c9/b6/ed5071f9325845e670bd36012757419767fbf56af77ed483077b9e4db541/librt-0.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55456ea87d8df21808446d03817be2f65e20391c1c615d9187440dff28cd08dc", size = 502568 }, + { url = "https://files.pythonhosted.org/packages/7f/81/6450c67c3615d87704bcbc21323fafc69c799b06a044c447529f725d4b01/librt-0.15.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5a86a5a08c2235316bdb359d5dbb6ce0abfca7fac06363103e2c5af571d92f95", size = 496153 }, + { url = "https://files.pythonhosted.org/packages/e1/d6/5f52b722bc75076954b3bfd49be15ea362df4d580c6fb315d0f617100d30/librt-0.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56b6a368529bed262da40ce13f8fef590db0479819cca84f16a1f01ac356d0b", size = 513336 }, + { url = "https://files.pythonhosted.org/packages/8d/e2/c08fd1d36ce63ea5a12b85c5d37f4550b5f86a692167e41e5a74222607ae/librt-0.15.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:234d8d394721fa0d786af15ebf1f3fb7f3ed82fd1cd0cde45c2f247b5d4281d2", size = 531661 }, + { url = "https://files.pythonhosted.org/packages/3f/d8/d9482fcbeb177b9eb87bb3899eeb3b42be690313c652f9e146b1d0681fb2/librt-0.15.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8363d7accb0286ac3a0e633f396e93800dafb8150494505daf9515bbda591f3", size = 524487 }, + { url = "https://files.pythonhosted.org/packages/10/cc/075171517b41f861753034fbb151b42cfc83bcc853849f24f5e66fd60ccf/librt-0.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0f0ee3644d951f31055ad07d77d92520e84505dd7a432cc4cd501dd70ee06785", size = 543201 }, + { url = "https://files.pythonhosted.org/packages/b0/03/42c2330f37eeb475b6affeedd06518f60035f323af3a839335e3fc9fef2d/librt-0.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2cfd1a81a648806e6a7717be4cc4d1bb392fa229752bf8444ba365e381e984d6", size = 546467 }, + { url = "https://files.pythonhosted.org/packages/57/1e/1ad4c5638f7e64d8560328bd25c54b409a661bdb6ff254b38ff90744288d/librt-0.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a6cd22c9da0d866558e46a041f1cc0c2bbb26b61b137b2347fa834c332e1d101", size = 555139 }, + { url = "https://files.pythonhosted.org/packages/49/41/39fa7d15db1204cd1cbe6514680fbdc243adf754a0885061308f43afc013/librt-0.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6d5225ef8801e4ea5e482fa9b5dfb891dd9ef6f6d870f1f25d449ca2c70ac218", size = 536050 }, + { url = "https://files.pythonhosted.org/packages/1e/88/c6dcf0dd8e26dc0c9a499a2abab8646c86dcaf9ecea9524cb46d3686331a/librt-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d28a05796b99f749bf8794f17ba9ba1612d0076b802e9cfc62c554634e9ce3b", size = 573700 }, + { url = "https://files.pythonhosted.org/packages/1b/9b/ab54c71a7918a7c34fa5327fb61390a77446a07a146fbfb1165250a61035/librt-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:2067ff438048cead9d223ca5675bae2a25e520a7c3e6c1498bf9c6892d22caab", size = 82194 }, + { url = "https://files.pythonhosted.org/packages/8d/b2/4f9a243bb892395f3becb80789ade13771701091f9f07ab8230247953ba8/librt-0.15.0-cp313-cp313-win32.whl", hash = "sha256:1cd3b721f24c206398b9e26da3c3a9c011e6e89d06f318ba8ebefc30f1003890", size = 106231 }, + { url = "https://files.pythonhosted.org/packages/bf/af/64aff4885a40b93132382f2c314647d722574605416504379184ef3045ea/librt-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:f395a4a9a03ac062dbe9a9f82e0c720502e590a38feee6a757bc82e9c63afbd8", size = 126996 }, + { url = "https://files.pythonhosted.org/packages/27/83/335bccf6c7cb9028cb0b54aead27d9ece3f01f83bc6baa2abace5da655c1/librt-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:0a15cb554761247d84a3ec0cbdf4078d70725384f0e4662c0fa3b26266eb60ad", size = 112188 }, + { url = "https://files.pythonhosted.org/packages/a8/93/949053fb462eecc4a9a5ee770a81f4b40be7b79538b245545d4aebc6b58b/librt-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f5de7feedc56337a088eb15cd9fafa9938367362221d8cc62c642b7f94821993", size = 149833 }, + { url = "https://files.pythonhosted.org/packages/61/ca/8281aa6cd560a3420e4497729f6b704b53be3eeaaef82d5aeadddaf7441f/librt-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c0eb900c0e91f4aebe680845242e614f1864edfd44106380d0752ac29522bf8", size = 154088 }, + { url = "https://files.pythonhosted.org/packages/dd/02/1a1662dceaba6a086360891448d5ce9a7d3555976cae59a31a39d744b9c7/librt-0.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8c9a650a188e38bac005048cbe6342e81407782944d01934540ab75e417df21", size = 494215 }, + { url = "https://files.pythonhosted.org/packages/69/84/99211619dc656370a3740c33d2b0b6d5a3fb1e73689314f6ed477a397dc4/librt-0.15.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:92bfed8deec93df30286b9fe9e3b1dd17329cc076a192b4ee5ec223841d54953", size = 491173 }, + { url = "https://files.pythonhosted.org/packages/d4/aa/5448d0b05f4579b635d3899176817ebf561af0e57bacd425b5b1887264c1/librt-0.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec4b19788f835711a2072f9dbe6b03b3bf32ed1f0fb30cf399bdd59d9f0c33fa", size = 505512 }, + { url = "https://files.pythonhosted.org/packages/95/82/01940e40b83c43a546c4a3c896cf34ca272a9690899d55914e4827b3dcce/librt-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4c7bacb70930f3d0a56f4ecf1be474a1f0d941b01dd73b756f3c256d42cb879", size = 523073 }, + { url = "https://files.pythonhosted.org/packages/88/fa/759c0030f3ee371439eb26de34fc745807caf0abb878af7af4b8b7c3dd3d/librt-0.15.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e79f05e4a08b4d880342673312bbc895b56df7765605796f15902eb5367d3ae", size = 515080 }, + { url = "https://files.pythonhosted.org/packages/0b/27/894e072228fcb159703c655da69f8cd10dbed489c36e3df7dd032a2483be/librt-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a417149c0cba4d50b61e992e5a15e69eaf96746609b461cc4ed168aeef6b79dd", size = 534164 }, + { url = "https://files.pythonhosted.org/packages/98/a3/0078e91c1f36f8815db17827de15650b9a3fe56c55fbf998c854b34e40d3/librt-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:da7a94d6a3411f579d72aa3e3bc5fbca7ed4549f3dbd7e5de3aa567333374285", size = 540616 }, + { url = "https://files.pythonhosted.org/packages/86/33/81a29b796dd52a45e9ef7974c7732926e8f10f15b8d2be505665979f896d/librt-0.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:856f743ae607f2c1380eccb566c0038a9fb3eabf0fc2be2704d76d9f73557239", size = 545890 }, + { url = "https://files.pythonhosted.org/packages/05/82/8be1baa1350e5d30cfd70ae79d0a6f4dc5862ef47f7bb2808aabc9bb86e5/librt-0.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:779a6e7c894737e5983e7790a9c78c4000c30e23c9aada08081bdbea53b0fa60", size = 523287 }, + { url = "https://files.pythonhosted.org/packages/c6/4f/d1be6a01a35c20ef734e0e44113f87d4af756a9354a89dcfbe3b4f8af5e1/librt-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96bb17dbe8bab3c0954fbebfc69ed395599de75b6bbc35e3270a878e15d4dd65", size = 565868 }, + { url = "https://files.pythonhosted.org/packages/67/88/649cfa33f5825927b160610f670bdab012a64d627eddb94fa795ea4292fd/librt-0.15.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:7220697efaa6e5348fc3d18ee7f8563d4bfecd9872b37ffb915bfc1d08840622", size = 81619 }, + { url = "https://files.pythonhosted.org/packages/22/31/8e88a8d5e48fc8d1a817787fb6811dfff6499acd6c8683dd83934aa6ede0/librt-0.15.0-cp314-cp314-win32.whl", hash = "sha256:f54598964d357b1c5ab77cf5d92f21e598fe0e23cdbe9618480807f81b4eba15", size = 100138 }, + { url = "https://files.pythonhosted.org/packages/80/92/20fd6c4b6a1b1a564b076d55cd3d427d8428217d7638dc25a654cc4791d4/librt-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ff5893a2c23d886aa9ce786de5ac6ddc74aeeaf90743682b74d920e117d2e28", size = 121258 }, + { url = "https://files.pythonhosted.org/packages/fc/28/6af430b44d9ebb897b865a3c363b6dcace51357be2347cc0f8f869656a86/librt-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:3722a099730704c9a3d70c879fc0f51daec25fe5f1555672d97bc595abeafb95", size = 106467 }, + { url = "https://files.pythonhosted.org/packages/7e/aa/b42bb798942ced219f6d63b27e07f91237887a8d0bd0921666db79a13790/librt-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:38c0c7d4b6fc06c3324b3f9162c8391bfc4fd9dde53afe1033ce7edb48d5a714", size = 159523 }, + { url = "https://files.pythonhosted.org/packages/75/03/1b53cd4ef904e73b1d828a5f90143bf94a2967d7cfff0b9ccf93e12aa9b4/librt-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8b2fdd7ead3c995c37940a790690660d0ca006c302db26cc51933f6766866fc3", size = 161638 }, + { url = "https://files.pythonhosted.org/packages/ac/c4/9f9c9fba097d49e9e694c2b4dc331df31884645ecbc58a93b4b5fc69d2c5/librt-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fde98cf1fc4bac144ce23c2c4c017b924ba714509ea9334977b0b27050c837d", size = 701795 }, + { url = "https://files.pythonhosted.org/packages/4c/05/0966840bda0380c8ae167b9043c6230202941cc90ea29c48e096964c765e/librt-0.15.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e3b461183c5fa7681b48560f91515f53a953122fb30c71e07abc67d7ddf58c38", size = 682147 }, + { url = "https://files.pythonhosted.org/packages/18/af/1c47ca573c30ea47d195aec26133af522fea1104afaace028d7b32247ea8/librt-0.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4bbcc257e3babea20a91715c361b24554ec4e8f51aa578568afc230799fe1a19", size = 696397 }, + { url = "https://files.pythonhosted.org/packages/2e/0f/1aed6223d4f9f9d1171a8596ff100ea4c3f7699fea7a4ba657c3e60daa6c/librt-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b845b8d48088fad0cadc84be4b8fda63203be7e9237b71015b3925443c1f35ab", size = 722542 }, + { url = "https://files.pythonhosted.org/packages/c6/22/9e3a929aea456c97d69e6ef3884efea56d4807f97399471cc946baebd8af/librt-0.15.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b30e600e8f337b9bd7f39b86d9fdfedc73cc46e3d0f745931a23a234220bb7e2", size = 729709 }, + { url = "https://files.pythonhosted.org/packages/e9/1b/c327ef6018e3a9ca0b8e7c5eddeeb331ba8f9b76c24e126d37d0f6d62faf/librt-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:64b0c8c35aa4c4ed79896359f3e0b285cbe4e610042106500da4811c322cc108", size = 752891 }, + { url = "https://files.pythonhosted.org/packages/d7/d1/d5f1ea02c56930087009e39db9b70660a663e76c730b27b925d786718457/librt-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0da0d94cb802f32a0524653e7201f2cef72d5f700a5407678f5290483d4fcd08", size = 745301 }, + { url = "https://files.pythonhosted.org/packages/d9/3c/5f7c585d15ebb2250c73e7c0ee4e9e47be72c65d520c07ddbcdc62037674/librt-0.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4a6369168d371207339b1e50d4532b06a7121586141f82599505a3f315751d47", size = 747921 }, + { url = "https://files.pythonhosted.org/packages/7f/52/1443a446486eba966bcbca1696b472e4f210320ec42f490a47f48fbf0fdc/librt-0.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c434e072557ade9cbc642d052c89d031efe47d5c9614523619d0d74a02378e81", size = 727561 }, + { url = "https://files.pythonhosted.org/packages/79/91/2270a9380f11725cf83ce1925a5e32dd1dde2be9bba597f25c10a38644e7/librt-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7eec6a42018bc1d45763b1c162d3d2bf7c3b9a1b0ed30d3e91dcba390efefcc", size = 774417 }, + { url = "https://files.pythonhosted.org/packages/9e/3b/f4b1548d4f5b99186737fe27aec238e9823e8d5d23bf4df007c030689dc5/librt-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:6912fa5e635d74529ac7cdb1bdf6ca3af4453da8d1edbe0110ee1cb4ad407ebf", size = 104381 }, + { url = "https://files.pythonhosted.org/packages/80/b6/134afad262def1de04c0843c376d02135f1168af43f22e09a52bd8394727/librt-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8e11699ed745931c395acd3621b07062e0f840efa6935aad87a64ed0995f0915", size = 127034 }, + { url = "https://files.pythonhosted.org/packages/99/5f/1b6846b20572bd699c9e9ec321a5f781845bee477df2aa2a43b28bc40119/librt-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5d2a91724463bfed4f573cd7a9fdc856d2e230d0c0e5a61416a93481dccd8605", size = 110827 }, + { url = "https://files.pythonhosted.org/packages/c6/44/4de9f4ddadb009a55c7758eb5736d62534a7daaf27bd71bc50e64b606b06/librt-0.15.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8443e38dcfcfdbcf5add5118c623efd788d65ac2e25756d6251a54a06a4d0aca", size = 149843 }, + { url = "https://files.pythonhosted.org/packages/1f/eb/5d9ab71e30119c44094e0275f38b47dd327aea0f843a080396677029d508/librt-0.15.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6d15a29033c57490cfe2069097c6fc4049e4e65ffbb749be7dc453b7c4c68965", size = 154510 }, + { url = "https://files.pythonhosted.org/packages/d0/9c/8505d1b8f5e8c19587bd03f7429993b3e9ce5c06819d856bfb11d919374c/librt-0.15.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2c05c729b589e734c09578bf5964be48a911765484840d017bbc84f49d4c4ad", size = 497543 }, + { url = "https://files.pythonhosted.org/packages/1d/9a/3a8390775cb095765aded027ac9c63e7c8ea74e731498607544c6505de0e/librt-0.15.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fa60887537e1d0cd2d9982269d33a709bf54b195cd2b9364fc0a758022af5bd9", size = 480452 }, + { url = "https://files.pythonhosted.org/packages/e7/40/258a4a7117ee915d66de5cd9b8ade65a440993161107ce3a686f1859955c/librt-0.15.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d8bc24219b24c0af375718942ab75e3544b2763085f40f965be4326734ae8328", size = 507768 }, + { url = "https://files.pythonhosted.org/packages/6b/c6/2f4dd296c97a0b85b98894519b279408ec9dd602d4f692b1ea0e25dee670/librt-0.15.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86a21a7bd3fe3a419512ef424cc1c020f6771d0b29cfddff36d1635a855e63f0", size = 525122 }, + { url = "https://files.pythonhosted.org/packages/49/dd/29eab42be13b2bf0ea8cb227135a45d44693e30a7e8b92871981ff56b82b/librt-0.15.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dbab647e88d90b3167b91efe7091e248653688ed4337e4f90907a722c7361bb9", size = 520371 }, + { url = "https://files.pythonhosted.org/packages/91/ed/4bad71adeca8fe208b775c2a35417fa5a2584c8f4791daaf89a89450fea1/librt-0.15.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d8edcf6f550e918dca779c069b9e156385c60b406f99fc7641f32c52f7193659", size = 537258 }, + { url = "https://files.pythonhosted.org/packages/4c/63/59dba6143fdcc7240c54458b629f3250000a61b8945890fc9efd451b19c5/librt-0.15.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:8b62076030baa2d8b1501a46bf0e19c27a489aa90671c55665bff7887f7660b0", size = 527432 }, + { url = "https://files.pythonhosted.org/packages/ec/21/21a24c6a2327d8362580efebe77286bf47b0f4062ec5ea41766e609d3c7d/librt-0.15.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:d00d20d1818e82a07a0ee0aa89a98b17ed7916b92441090b683719cb20a59b6d", size = 548108 }, + { url = "https://files.pythonhosted.org/packages/5a/6d/fc68c89a7971418b41f9a873623ff935cb864097544c6a2f8ce491c8ef5d/librt-0.15.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4e6ee93fc3cf848dcbf0cce2eca73d8e7dcd0cc2b6df3a529d57750b30a4c55c", size = 529681 }, + { url = "https://files.pythonhosted.org/packages/65/7e/c2d98766124400d722063a630b0fde38a9fc768705d37eecca15c47dc192/librt-0.15.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:32896a0af72508ea979e0acb4e4c04cbeeae04938167950d535c83c45597167d", size = 567736 }, + { url = "https://files.pythonhosted.org/packages/55/6c/f8c34a95e3a515c6e1c192b89511e7253c89a7760c6b500d57ffdb8d2dc8/librt-0.15.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:ec3ba415afaf951f6951b1dd16d3c8e4f540065fc382d7e70b823a79567ca374", size = 81673 }, + { url = "https://files.pythonhosted.org/packages/c9/9e/e23fa8e78679ec45728188650b39e8ff476c83b691c96f749217df3b1b7c/librt-0.15.0-cp315-cp315-win32.whl", hash = "sha256:d2813ba2503764f0450680c533d13df7cff9b49df1411062eded5f67db4195b9", size = 100081 }, + { url = "https://files.pythonhosted.org/packages/e1/dc/3eb4c5e297343f0620a55532cd7c8d764d3001fa2159212dadf480464827/librt-0.15.0-cp315-cp315-win_amd64.whl", hash = "sha256:b87d67e33afaf265262f2a66db578284b88ee2e6fcd224579cb5c15518677ad8", size = 121228 }, + { url = "https://files.pythonhosted.org/packages/97/70/43abce19f04e49762f8ec834c8fafee13cc40fd6b94a72a24e534febfcd0/librt-0.15.0-cp315-cp315-win_arm64.whl", hash = "sha256:713bd7df21170b982e729e46870f31d6b437bd1a9b4648cffb529bd3c2ec5c4b", size = 106487 }, + { url = "https://files.pythonhosted.org/packages/de/15/83f2deddb9368b8951ec8c9477269b5b9b8bd9bbf15e57402d0f38817dca/librt-0.15.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3de789c82752730f94782a5ee518baf9c05edf85733aeaf73bb6e518755cdf54", size = 159448 }, + { url = "https://files.pythonhosted.org/packages/06/bf/043097353f9b3c73b583d07f6b8e552795463f4bfc8caf85e42eee50c26a/librt-0.15.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e0b5deec9a8664eb722c797241970fd4aa1894d25fda36a1ddac0f7407606bd6", size = 161686 }, + { url = "https://files.pythonhosted.org/packages/f4/2a/8ae77f9719d42ce71cd708560a3557b38ac3c17a0383e57f87084de45bbe/librt-0.15.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5563302a8359bc2295bb7084d1a8ed1519df96afb30eb2aa4e0bff7b54228988", size = 710668 }, + { url = "https://files.pythonhosted.org/packages/61/34/c0436ea134deb9a0d6da80a396a2739a81cb31e0418f7227239e23140898/librt-0.15.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:22d6263b9d39d7bbb286fa791945646e3218f1be2d693e36fb630f1d0e59cd13", size = 679396 }, + { url = "https://files.pythonhosted.org/packages/4a/9f/001e0d99aa9250d5cd5715a9081291a20656083459f9019cda15255329e1/librt-0.15.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39ffd14646190c454f0d86e0d256b33f00a87a26ab410e619773b841d0e41416", size = 704313 }, + { url = "https://files.pythonhosted.org/packages/2d/53/b34fa9d0ff00f136f4d58ebb4c411ff634baed1eb412bb602a2bc8dcafcb/librt-0.15.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47318cd3a61401452de11282242937e3e057c4fd3dbaf601e269d0928a06c0a", size = 729847 }, + { url = "https://files.pythonhosted.org/packages/86/ac/fa4d7a424665040e95baf480a6d523446057684b6758624c85338e8a23b2/librt-0.15.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a56a1d4f859a82ca5b99fc4b82c9b027b15e3c455c5cd99e7d0719f27bb20b6c", size = 742736 }, + { url = "https://files.pythonhosted.org/packages/8a/f1/e17a9bb5de6fb8c3186ed1a7d68d21618b027ac2d3633e03d3b6109c67ae/librt-0.15.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:077471b3182db4e17c36ae91555f36a4d2c00080b267f749bcad34a478a9a302", size = 763454 }, + { url = "https://files.pythonhosted.org/packages/1d/ec/ecd02cd30935b931b9cdbfed6ab5a099c51b280b4e7baa274da80978ed27/librt-0.15.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:411ca4d1b905b860ceba7570dd6717a71dedaddcc4b0f77ece710aa41ee11f8d", size = 743296 }, + { url = "https://files.pythonhosted.org/packages/e6/b5/b3c2b8353ce820a4854f78d19321344242f89fa71c975b71132ba9bf242a/librt-0.15.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1256589e0b0adb31751d685a68bce29d73407ddf4ef05d4188f49d5dcf9566d9", size = 756217 }, + { url = "https://files.pythonhosted.org/packages/3c/52/6cc22542ba59146b05cca2a656f9ff8bb67e38e63d12c3b0cc183d837bf1/librt-0.15.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:f42b74a53e5f26a0ba0007411a7455b66c67ce4022a39cc1f56fc4efd65bcbab", size = 741934 }, + { url = "https://files.pythonhosted.org/packages/40/32/a04b72b1aa86e3be23b2ecff8c1aad2dcc955bd3956d6d26e7e34267e57a/librt-0.15.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:291bf73caf78b9e88d6fae9bfd693207ff7d832e2fdbe2cf8e746bc13f5f892b", size = 783763 }, + { url = "https://files.pythonhosted.org/packages/6c/f0/89eb11dffbe9279ff37144dec786927314502ae0b114f1449dc78c458aab/librt-0.15.0-cp315-cp315t-win32.whl", hash = "sha256:c16d15ee371643ab48dc8248a3e680ebbeca573a13af2c3dd0c985b142d77162", size = 104313 }, + { url = "https://files.pythonhosted.org/packages/6d/4a/1f1978c200f563beda63c36adff2d65bbecb81e365e8e69e572f5f70fbc6/librt-0.15.0-cp315-cp315t-win_amd64.whl", hash = "sha256:dbd605739f228912dc49027cb764456b9757750bdc2b6b7773164db7096c6fd1", size = 126889 }, + { url = "https://files.pythonhosted.org/packages/38/a6/800800bfed7b1fb10fc3f3d557785c3854e80d3f7a9800d784b176a1fc2d/librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc", size = 110700 }, +] + +[[package]] +name = "mypy" +version = "1.20.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/4d/9ebeae211caccbdaddde7ed5e31dfcf57faac66be9b11deb1dc6526c8078/mypy-1.20.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4077797a273e56e8843d001e9dfe4ba10e33323d6ade647ff260e5cd97d9758c", size = 14371307 }, + { url = "https://files.pythonhosted.org/packages/95/d7/93473d34b61f04fac1aecc01368485c89c5c4af7a4b9a0cab5d77d04b63f/mypy-1.20.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cdecf62abcc4292500d7858aeae87a1f8f1150f4c4dd08fb0b336ee79b2a6df3", size = 13258917 }, + { url = "https://files.pythonhosted.org/packages/e2/30/3dd903e8bafb7b5f7bf87fcd58f8382086dea2aa19f0a7b357f21f63071b/mypy-1.20.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c566c3a88b6ece59b3d70f65bedef17304f48eb52ff040a6a18214e1917b3254", size = 13700516 }, + { url = "https://files.pythonhosted.org/packages/07/05/c61a140aba4c729ac7bc99ae26fc627c78a6e08f5b9dd319244ea71a3d7e/mypy-1.20.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0deb80d062b2479f2c87ae568f89845afc71d11bc41b04179e58165fd9f31e98", size = 14562889 }, + { url = "https://files.pythonhosted.org/packages/fd/87/da78243742ffa8a36d98c3010f0d829f93d5da4e6786f1a1a6f2ad616502/mypy-1.20.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bba9ad231e92a3e424b3e56b65aa17704993425bba97e302c832f9466bb85bac", size = 14803844 }, + { url = "https://files.pythonhosted.org/packages/37/52/10a1ddf91b40f843943a3c6db51e2df59c9e237f29d355e95eaab427461f/mypy-1.20.2-cp311-cp311-win_amd64.whl", hash = "sha256:baf593f2765fa3a6b1ef95807dbaa3d25b594f6a52adcc506a6b9cb115e1be67", size = 10846300 }, + { url = "https://files.pythonhosted.org/packages/20/02/f9a4415b664c53bd34d6709be59da303abcae986dc4ac847b402edb6fa1e/mypy-1.20.2-cp311-cp311-win_arm64.whl", hash = "sha256:20175a1c0f49863946ec20b7f63255768058ac4f07d2b9ded6a6b46cfb5a9100", size = 9779498 }, + { url = "https://files.pythonhosted.org/packages/71/4e/7560e4528db9e9b147e4c0f22660466bf30a0a1fe3d63d1b9d3b0fd354ee/mypy-1.20.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4dbfcf869f6b0517f70cf0030ba6ea1d6645e132337a7d5204a18d8d5636c02b", size = 14539393 }, + { url = "https://files.pythonhosted.org/packages/32/d9/34a5efed8124f5a9234f55ac6a4ced4201e2c5b81e1109c49ad23190ec8c/mypy-1.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b6481b228d072315b053210b01ac320e1be243dc17f9e5887ef167f23f5fae4", size = 13361642 }, + { url = "https://files.pythonhosted.org/packages/d1/14/eb377acf78c03c92d566a1510cda8137348215b5335085ef662ab82ecd3a/mypy-1.20.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34397cdced6b90b836e38182076049fdb41424322e0b0728c946b0939ebdf9f6", size = 13740347 }, + { url = "https://files.pythonhosted.org/packages/b9/94/7e4634a32b641aa1c112422eed1bbece61ee16205f674190e8b536f884de/mypy-1.20.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5da6976f20cae27059ea8d0c86e7cef3de720e04c4bb9ee18e3690fdb792066", size = 14734042 }, + { url = "https://files.pythonhosted.org/packages/7a/f3/f7e62395cb7f434541b4491a01149a4439e28ace4c0c632bbf5431e92d1f/mypy-1.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56908d7e08318d39f85b1f0c6cfd47b0cac1a130da677630dac0de3e0623e102", size = 14964958 }, + { url = "https://files.pythonhosted.org/packages/3e/0d/47e3c3a0ec2a876e35aeac365df3cac7776c36bbd4ed18cc521e1b9d255b/mypy-1.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:d52ad8d78522da1d308789df651ee5379088e77c76cb1994858d40a426b343b9", size = 10911340 }, + { url = "https://files.pythonhosted.org/packages/d6/b2/6c852d72e0ea8b01f49da817fb52539993cde327e7d010e0103dc12d0dac/mypy-1.20.2-cp312-cp312-win_arm64.whl", hash = "sha256:785b08db19c9f214dc37d65f7c165d19a30fcecb48abfa30f31b01b5acaabb58", size = 9833947 }, + { url = "https://files.pythonhosted.org/packages/5b/c4/b93812d3a192c9bcf5df405bd2f30277cd0e48106a14d1023c7f6ed6e39b/mypy-1.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:edfbfca868cdd6bd8d974a60f8a3682f5565d3f5c99b327640cedd24c4264026", size = 14524670 }, + { url = "https://files.pythonhosted.org/packages/f3/47/42c122501bff18eaf1e8f457f5c017933452d8acdc52918a9f59f6812955/mypy-1.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e2877a02380adfcdbc69071a0f74d6e9dbbf593c0dc9d174e1f223ffd5281943", size = 13336218 }, + { url = "https://files.pythonhosted.org/packages/92/8f/75bbc92f41725fbd585fb17b440b1119b576105df1013622983e18640a93/mypy-1.20.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7488448de6007cd5177c6cea0517ac33b4c0f5ee9b5e9f2be51ce75511a85517", size = 13724906 }, + { url = "https://files.pythonhosted.org/packages/a1/32/4c49da27a606167391ff0c39aa955707a00edc500572e562f7c36c08a71f/mypy-1.20.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb9c2fa06887e21d6a3a868762acb82aec34e2c6fd0174064f27c93ede68ad15", size = 14726046 }, + { url = "https://files.pythonhosted.org/packages/7f/fc/4e354a1bd70216359deb0c9c54847ee6b32ef78dfb09f5131ff99b494078/mypy-1.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d56a78b646f2e3daa865bc70cd5ec5a46c50045801ca8ff17a0c43abc97e3ee", size = 14955587 }, + { url = "https://files.pythonhosted.org/packages/62/b2/c0f2056e9eb8f08c62cafd9715e4584b89132bdc832fcf85d27d07b5f3e5/mypy-1.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:2a4102b03bb7481d9a91a6da8d174740c9c8c4401024684b9ca3b7cc5e49852f", size = 10922681 }, + { url = "https://files.pythonhosted.org/packages/e5/14/065e333721f05de8ef683d0aa804c23026bcc287446b61cac657b902ccac/mypy-1.20.2-cp313-cp313-win_arm64.whl", hash = "sha256:a95a9248b0c6fd933a442c03c3b113c3b61320086b88e2c444676d3fd1ca3330", size = 9830560 }, + { url = "https://files.pythonhosted.org/packages/ae/d1/b4ec96b0ecc620a4443570c6e95c867903428cfcde4206518eafdd5880c3/mypy-1.20.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:419413398fe250aae057fd2fe50166b61077083c9b82754c341cf4fd73038f30", size = 14524561 }, + { url = "https://files.pythonhosted.org/packages/3a/63/d2c2ff4fa66bc49477d32dfa26e8a167ba803ea6a69c5efb416036909d30/mypy-1.20.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e73c07f23009962885c197ccb9b41356a30cc0e5a1d0c2ea8fd8fb1362d7f924", size = 13363883 }, + { url = "https://files.pythonhosted.org/packages/2a/56/983916806bf4eddeaaa2c9230903c3669c6718552a921154e1c5182c701f/mypy-1.20.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c64e5973df366b747646fc98da921f9d6eba9716d57d1db94a83c026a08e0fb", size = 13742945 }, + { url = "https://files.pythonhosted.org/packages/19/65/0cd9285ab010ee8214c83d67c6b49417c40d86ce46f1aa109457b5a9b8d7/mypy-1.20.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a65aa591af023864fd08a97da9974e919452cfe19cb146c8a5dc692626445dc", size = 14706163 }, + { url = "https://files.pythonhosted.org/packages/94/97/48ff3b297cafcc94d185243a9190836fb1b01c1b0918fff64e941e973cc9/mypy-1.20.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fef51b01e638974a6e69885687e9bd40c8d1e09a6cd291cca0619625cf1f558", size = 14938677 }, + { url = "https://files.pythonhosted.org/packages/fd/a1/1b4233d255bdd0b38a1f284feeb1c143ca508c19184964e22f8d837ec851/mypy-1.20.2-cp314-cp314-win_amd64.whl", hash = "sha256:913485a03f1bcf5d279409a9d2b9ed565c151f61c09f29991e5faa14033da4c8", size = 11089322 }, + { url = "https://files.pythonhosted.org/packages/78/c2/ce7ee2ba36aeb954ba50f18fa25d9c1188578654b97d02a66a15b6f09531/mypy-1.20.2-cp314-cp314-win_arm64.whl", hash = "sha256:c3bae4f855d965b5453784300c12ffc63a548304ac7f99e55d4dc7c898673aa3", size = 10017775 }, + { url = "https://files.pythonhosted.org/packages/4e/a1/9d93a7d0b5859af0ead82b4888b46df6c8797e1bc5e1e262a08518c6d48e/mypy-1.20.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2de3dcea53babc1c3237a19002bc3d228ce1833278f093b8d619e06e7cc79609", size = 15549002 }, + { url = "https://files.pythonhosted.org/packages/00/d2/09a6a10ee1bf0008f6c144d9676f2ca6a12512151b4e0ad0ff6c4fac5337/mypy-1.20.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:52b176444e2e5054dfcbcb8c75b0b719865c96247b37407184bbfca5c353f2c2", size = 14401942 }, + { url = "https://files.pythonhosted.org/packages/57/da/9594b75c3c019e805250bed3583bdf4443ff9e6ef08f97e39ae308cb06f2/mypy-1.20.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:688c3312e5dadb573a2c69c82af3a298d43ecf9e6d264e0f95df960b5f6ac19c", size = 15041649 }, + { url = "https://files.pythonhosted.org/packages/97/77/f75a65c278e6e8eba2071f7f5a90481891053ecc39878cc444634d892abe/mypy-1.20.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29752dbbf8cc53f89f6ac096d363314333045c257c9c75cbd189ca2de0455744", size = 15864588 }, + { url = "https://files.pythonhosted.org/packages/d7/46/1a4e1c66e96c1a3246ddf5403d122ac9b0a8d2b7e65730b9d6533ba7a6d3/mypy-1.20.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:803203d2b6ea644982c644895c2f78b28d0e208bba7b27d9b921e0ec5eb207c6", size = 16093956 }, + { url = "https://files.pythonhosted.org/packages/5a/2c/78a8851264dec38cd736ca5b8bc9380674df0dd0be7792f538916157716c/mypy-1.20.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9bcb8aa397ff0093c824182fd76a935a9ba7ad097fcbef80ae89bf6c1731d8ec", size = 12568661 }, + { url = "https://files.pythonhosted.org/packages/83/01/cd7318aa03493322ce275a0e14f4f52b8896335e4e79d4fb8153a7ad2b77/mypy-1.20.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e061b58443f1736f8a37c48978d7ab581636d6ab03e3d4f99e3fa90463bb9382", size = 10389240 }, + { url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314 }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963 }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956 }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151 }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750 }, +] + +[[package]] +name = "ruff" +version = "0.16.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/61/b3/3213589383f8f1b3938781bd1278713f6d18621a14992b3e81fefb8a5ef9/ruff-0.16.3.tar.gz", hash = "sha256:e76d33a347661a84b5be6d043d0347fdc745dfdcf825a8f4fed64b5e26eebdf2", size = 4891904 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/96/493770daebd68c0a67f1549fdf519f53be51fc435186c0585bcc272fd76c/ruff-0.16.3-py3-none-linux_armv6l.whl", hash = "sha256:0c5710e247a58a4521e66e124ba9a74655b414f61ba3a2e9e3811e11098f48f7", size = 10902799 }, + { url = "https://files.pythonhosted.org/packages/5e/e6/2becf3942fddc29a29b8df47691d456fb1085391a694f74d84513251418c/ruff-0.16.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fe155130631a2471fd2e14a7a664a4dfbd7194b8229c3d7b2a40b21178639081", size = 11135539 }, + { url = "https://files.pythonhosted.org/packages/3e/1e/4b8b72f0d006dbf19326aa99f9ca0ee2ff374187c4d301cf529a51aa06fe/ruff-0.16.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e2ed719e14aa64d895c2ee922594a90a43c861a93f0575a95ff8c47cdbd13eb9", size = 10475095 }, + { url = "https://files.pythonhosted.org/packages/92/32/2201fa49ba1f6c101ee321e83f051ac7a4b8d07b0ef6b4d3f2772b302275/ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e0b1da805eb043654645d74d5de1e5ce2edc686e40790d2b86f56d71cc06a84", size = 10668771 }, + { url = "https://files.pythonhosted.org/packages/c3/66/4afc5c8363bd04d45effce1b7c8713ca037d7a6740b7451a2403a6e3a972/ruff-0.16.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a37bdea0bbe21780f590bf437d6412c8c4e1b6cd010f91a65c2c40c5e5f5f870", size = 10699568 }, + { url = "https://files.pythonhosted.org/packages/53/fd/c67d246bf36bf1698551c56de39e95cd07f70e64433e0098e6267d77061b/ruff-0.16.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09571e6d1288ed9be475207a3ac04ada404f1cd898104be0f6ab8d7df438575b", size = 11499365 }, + { url = "https://files.pythonhosted.org/packages/67/0b/00ecbceb99a263af7b12f6f05ac3c92bc47b905e91adc3f207a836e3bc01/ruff-0.16.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c18c5a101eb540010638cc1ff3c84944d3adb3df62b8d98ca8f22ba484d3413", size = 12311728 }, + { url = "https://files.pythonhosted.org/packages/54/b2/b7b3bb54f4d3f7db504e476ad4ab8de530dceebe2c061384b2757ee419e8/ruff-0.16.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8457c44f15033c85ddbb77b15d451df9e24e4bd03b628396dd3610cedc3b8f82", size = 11699896 }, + { url = "https://files.pythonhosted.org/packages/c7/30/4c468429ac195addc5ee1b717b6ab1b66632786737ca3b2ed3443fb0c26a/ruff-0.16.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:294b95c4ae0cda9388525c2047778aa758d6b8d4bb876fd4e9eaa3ebc92343eb", size = 11058736 }, + { url = "https://files.pythonhosted.org/packages/43/67/7a113cdaddf24b64d7f75b1242a99d04c82fcef4f6921fdbb832beaffb5f/ruff-0.16.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3d0c7c40c87c2a820509c31ba007968da6e1306468c067b2d82fbfdbcd0e8474", size = 11586911 }, + { url = "https://files.pythonhosted.org/packages/f1/c1/2e66f24c0f3ead25a5e660111778685e505e5da353c82802bf49f0cbe7b9/ruff-0.16.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9f738c0fdfa8eed0b2ce7fb27ee7258208a92a68d7949e62aa15164bc7b389da", size = 10954265 }, + { url = "https://files.pythonhosted.org/packages/c2/ba/4cee23bf52cba9a058d3726de623624daf50ef9638868edd86f4126157f6/ruff-0.16.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb785f0be25abe69d320415cd4f833b59e17ba7613d9ba6a958023b6bceb0a50", size = 10709886 }, + { url = "https://files.pythonhosted.org/packages/82/df/7da7194fa5d9dc0a285f7e6fa5a4722e7c63faac0b45b614ded9314363a1/ruff-0.16.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c5536e3acfbf9563085aa2be7b13c629c3077e902afc5b941ac44024dbb9f506", size = 11210392 }, + { url = "https://files.pythonhosted.org/packages/35/85/7795f6e817af050e7517bf3e7aa9b061cce70ef33d280aad902c956c1ecf/ruff-0.16.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a2d85c02f9b8e165d85e6779184d38c4132de12603dab59c51c28e22584f9e4d", size = 11626910 }, + { url = "https://files.pythonhosted.org/packages/78/9b/475b927cf27a5cbbda3c7bafb69ed6ff77e1d7923d5d85f17c2749d7ae32/ruff-0.16.3-py3-none-win32.whl", hash = "sha256:388cdf2166642bd9b13d52b5932d3170f34f8abed7e8d9a855f1d84b83645a0a", size = 10931415 }, + { url = "https://files.pythonhosted.org/packages/b2/99/e2a2bfc4fbf0a1e8a916bc9ebe6fe6c58cc34c28e0ffc6ce281d572d1c2e/ruff-0.16.3-py3-none-win_amd64.whl", hash = "sha256:e80a7d69ca2a6d1c4d352ec91458cdca6e56c83cdbcabd93e4abe1e53591d948", size = 11445993 }, + { url = "https://files.pythonhosted.org/packages/69/3e/4132e539aed78c148854d4997a2685b0ed4dc4e87110b59ce528564e184e/ruff-0.16.3-py3-none-win_arm64.whl", hash = "sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a", size = 11399302 }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571 }, +] From 5be8b61655de76e0717f577d0c5f65284ca9c96a Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Sat, 15 Aug 2026 14:05:48 -0600 Subject: [PATCH 17/17] Fix CI --- crates/lab-cli/tests/pdf_output.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/lab-cli/tests/pdf_output.rs b/crates/lab-cli/tests/pdf_output.rs index b56db91..8502d84 100644 --- a/crates/lab-cli/tests/pdf_output.rs +++ b/crates/lab-cli/tests/pdf_output.rs @@ -9,6 +9,9 @@ fn copy_dir(from: &Path, to: &Path) { std::fs::create_dir_all(to).unwrap(); for entry in std::fs::read_dir(from).unwrap() { let entry = entry.unwrap(); + if entry.file_name() == ".lab" { + continue; + } let target = to.join(entry.file_name()); if entry.file_type().unwrap().is_dir() { copy_dir(&entry.path(), &target); @@ -26,7 +29,12 @@ fn build_typesets_every_document_to_pdf() { copy_dir(&example, &project); let output = Command::new(env!("CARGO_BIN_EXE_lab")) - .args(["build", project.to_str().unwrap()]) + .args([ + "build", + project.to_str().unwrap(), + "--target", + "opentrons-ot2", + ]) .output() .unwrap(); assert!(