diff --git a/.gitignore b/.gitignore index ecd8176..b5ca555 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ .lab/ .venv/ .env +.c3.local *.rs.bk .DS_Store crates/lab-python/python/lab/_native.* @@ -16,3 +17,6 @@ __pycache__/ .mypy_cache/ .pytest_cache/ .ruff_cache/ +viewer/node_modules/ +viewer/dist/ +.claude/ diff --git a/Cargo.lock b/Cargo.lock index a3c66ae..6a5758e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1605,11 +1605,13 @@ dependencies = [ "anyhow", "clap", "flate2", - "hamilton-star", "lab-compiler", - "lab-instruments", + "lab-compute", "lab-package", "lab-project", + "lab-runfmt", + "lab-runtime", + "lab-scene", "self-replace", "semver", "serde", @@ -1635,6 +1637,7 @@ dependencies = [ "hamilton-star", "lab-instruments", "lab-language", + "lab-runfmt", "opentrons-protocol", "pliron", "serde", @@ -1643,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" @@ -1716,6 +1729,45 @@ dependencies = [ "toml", ] +[[package]] +name = "lab-runfmt" +version = "0.1.2" +dependencies = [ + "lab-instruments", + "serde", + "serde_json", + "tempfile", + "thiserror", + "toml", +] + +[[package]] +name = "lab-runtime" +version = "0.1.2" +dependencies = [ + "anyhow", + "hamilton-star", + "lab-instruments", + "lab-runfmt", + "serde", + "serde_json", + "tempfile", + "thiserror", +] + +[[package]] +name = "lab-scene" +version = "0.1.2" +dependencies = [ + "base64", + "lab-compiler", + "lab-runfmt", + "serde", + "serde_json", + "tempfile", + "thiserror", +] + [[package]] name = "lab-sdk-python" version = "0.1.2" diff --git a/Cargo.toml b/Cargo.toml index c00a91c..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", @@ -11,6 +12,9 @@ members = [ "crates/lab-package", "crates/lab-project", "crates/lab-python", + "crates/lab-runfmt", + "crates/lab-runtime", + "crates/lab-scene", ] [workspace.package] @@ -60,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 } @@ -72,6 +77,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..30522a8 100644 --- a/crates/lab-cli/Cargo.toml +++ b/crates/lab-cli/Cargo.toml @@ -16,10 +16,12 @@ 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. -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/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/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 f927670..d1e2dd9 100644 --- a/crates/lab-cli/src/main.rs +++ b/crates/lab-cli/src/main.rs @@ -1,5 +1,12 @@ mod commands; +mod compute; +mod flow; +mod render; +mod robot_task; mod run; +mod scene; +mod simulate; +mod stamp; mod typeset; mod update; mod workcell_run; @@ -86,6 +93,105 @@ 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 (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. + #[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, 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)] + 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, + /// 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, + }, + /// 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 { + /// 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. + #[arg(long, default_value = "dolly")] + camera: String, + /// 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)] + 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, + /// Facility description; defaults to the package's facility.toml + /// or its manifest's [build] facility pointer. + #[arg(long)] + facility: Option, + /// Blender processes per wave, each rendering a slice of the frame + /// range. Previews default to every core; path-traced finals to + /// 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 { /// Package directory or any path inside a package. @@ -100,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, } @@ -155,7 +295,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 +304,61 @@ 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, + 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, + speedup, + fps, + quality, + still, + hdri, + blender, + out_dir, + facility, + jobs, + hold_seconds, + uniform, + } => render::render( + path, + render::RenderOptions { + camera, + speedup, + fps, + quality, + still, + hdri, + blender, + out_dir, + facility, + jobs, + hold_seconds, + uniform, + }, + &output, + ), Command::Metadata { path } => commands::metadata(path, &output), Command::Update { check } => update::update(check, &output), } @@ -209,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/render.rs b/crates/lab-cli/src/render.rs new file mode 100644 index 0000000..675e282 --- /dev/null +++ b/crates/lab-cli/src/render.rs @@ -0,0 +1,563 @@ +//! 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, + 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 +/// 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); + } + if options.quality == "final" { + return 1; + } + std::thread::available_parallelism() + .map(|cores| cores.get()) + .unwrap_or(1) +} + +/// 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 +/// 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" + ); +} + +/// 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, 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()))?; + + // Skip Blender entirely when the documents and the settings that + // shape the footage both match the last run. + let settings = format!( + "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"); + 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())?; + + // 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()))?; + + 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(&script_trace) + .arg("--out") + .arg(&out_dir) + .arg("--camera") + .arg(&options.camera) + .arg("--speedup") + .arg(script_speedup.to_string()) + .arg("--fps") + .arg(options.fps.to_string()) + .arg("--quality") + .arg(&options.quality); + if let Some(still) = script_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 + }; + + // 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 { + 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 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(), + 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("; ")); + } + } + crate::stamp::write(&stamp_path, &print); + + // 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); + } + } + + Ok((out_dir, movie, false)) +} + +/// 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; + + let mut sections = Vec::new(); + let mut reports = Vec::new(); + for wave in &flow.waves { + let label = crate::flow::wave_label(wave); + let out_override = if single { + options.out_dir.clone() + } else { + None + }; + 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(), + 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")) +} + +#[cfg(test)] +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)]); + 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"); + } + } +} 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/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..0907f25 --- /dev/null +++ b/crates/lab-cli/src/scene.rs @@ -0,0 +1,288 @@ +//! 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) +} + +/// 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" { + 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 = 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, assets, facility)?) + } 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, assets)?) + } +} + +/// The simulated run to animate a USD layer from, when asked. +fn load_trace(directory: &Path) -> 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) +} + +/// 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, + out_dir: Option, +) -> 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()?; + 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) + .with_context(|| format!("failed to create {}", out_dir.display()))?; + + let scene_path = out_dir.join("scene.json"); + 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"); + 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), + }; + crate::stamp::write_if_changed(&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, + }, + false, + )) +} + +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, 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, 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 new file mode 100644 index 0000000..5bd26f0 --- /dev/null +++ b/crates/lab-cli/src/simulate.rs @@ -0,0 +1,207 @@ +//! 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::{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"; + +/// 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>, + trace_path: Option, +) -> 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) + .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 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)?; + 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); + } + Ok((trace, trace_path, false)) +} + +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, 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); + } + + 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, fresh) = simulate_wave(wave, flow.facility.as_deref(), None)?; + sections.push(format!( + "== {label}{} ==\n{}", + if fresh { " (up to date)" } else { "" }, + 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 { + 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/stamp.rs b/crates/lab-cli/src/stamp.rs new file mode 100644 index 0000000..ea7a283 --- /dev/null +++ b/crates/lab-cli/src/stamp.rs @@ -0,0 +1,159 @@ +//! 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 { + // 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); + 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); +} + +/// 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. +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")); + } + + #[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/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/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!( diff --git a/crates/lab-cli/tests/project_workflow.rs b/crates/lab-cli/tests/project_workflow.rs index a219ff1..5660b17 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(); @@ -577,3 +577,661 @@ 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(); +} + +#[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")) + .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("facility.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(); +} + +#[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(); +} + +/// 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(); +} + +#[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(); +} + +#[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/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-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-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..9650f7f --- /dev/null +++ b/crates/lab-runfmt/Cargo.toml @@ -0,0 +1,21 @@ +[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 } +toml = { workspace = true } + +[dev-dependencies] +tempfile = "3" + +[lints] +workspace = true diff --git a/crates/lab-runfmt/src/facility.rs b/crates/lab-runfmt/src/facility.rs new file mode 100644 index 0000000..64ea414 --- /dev/null +++ b/crates/lab-runfmt/src/facility.rs @@ -0,0 +1,439 @@ +//! 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}")] + Io { + path: String, + #[source] + source: std::io::Error, + }, + #[error("{path} is not a valid facility description")] + 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, + }, + #[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")] + pub storage: Vec, + #[serde(default, rename = "consumable")] + pub consumables: Vec, + #[serde(default)] + 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 { + 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, + /// 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, +/// 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> { + 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()) { + return Err(FacilityError::DuplicateStation { + facility: self.facility.name.clone(), + 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 { + 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: &[crate::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![ + crate::WorkcellStation { + name: "star-1".to_string(), + kind: "hamilton.star".to_string(), + program_dir: "stations/star-1".to_string(), + }, + crate::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![crate::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![crate::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_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 ="); + assert!(matches!( + Facility::parse("f.toml", &misspelled), + Err(FacilityError::Parse { .. }) + )); + } +} diff --git a/crates/lab-compiler/src/runfmt/mod.rs b/crates/lab-runfmt/src/lib.rs similarity index 60% rename from crates/lab-compiler/src/runfmt/mod.rs rename to crates/lab-runfmt/src/lib.rs index e1b924b..8f4f342 100644 --- a/crates/lab-compiler/src/runfmt/mod.rs +++ b/crates/lab-runfmt/src/lib.rs @@ -1,10 +1,29 @@ //! 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. + +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, +}; + +use std::path::Path; use serde::{Deserialize, Serialize}; @@ -20,6 +39,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}")] + Io { + path: String, + #[source] + source: std::io::Error, + }, + #[error("{path} is not a valid document")] + 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 +291,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-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/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 new file mode 100644 index 0000000..4dc9832 --- /dev/null +++ b/crates/lab-runtime/Cargo.toml @@ -0,0 +1,28 @@ +[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 } + +[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..c23fb51 --- /dev/null +++ b/crates/lab-runtime/src/events.rs @@ -0,0 +1,71 @@ +//! 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. + +pub use lab_runfmt::{ProgramExtent, RunEvent}; + +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/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..386050d --- /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 use lab_runfmt::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..f41d646 --- /dev/null +++ b/crates/lab-runtime/src/trace.rs @@ -0,0 +1,39 @@ +//! 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::rc::Rc; + +pub use lab_runfmt::{ + AttentionWindow, SIM_TRACE_FORMAT, SimSummary, SimTraceDocument, StationSummary, TimedEvent, + summarize, +}; + +use crate::clock::VirtualClock; +use crate::events::{EventSink, RunEvent}; + +/// 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, + }); + } +} 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..bcee8fa --- /dev/null +++ b/crates/lab-scene/Cargo.toml @@ -0,0 +1,22 @@ +[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 } + +[dev-dependencies] +tempfile = "3" + +[lints] +workspace = true 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/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/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..c642ead --- /dev/null +++ b/crates/lab-scene/src/gltf.rs @@ -0,0 +1,397 @@ +//! 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]], + })); + 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(); + // 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": extras, + })); + 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 }, + { "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 } + ]) +} + +/// 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, + Semantic::Part { material } => match material.as_str() { + "frame" => 6, + "panel" => 7, + "glass" => 8, + "accent" => 9, + _ => 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/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 new file mode 100644 index 0000000..d2523c6 --- /dev/null +++ b/crates/lab-scene/src/lib.rs @@ -0,0 +1,25 @@ +//! 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 animate; +pub mod assets; +pub mod dims; +pub mod gltf; +pub(crate) mod instruments; +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..c1c7243 --- /dev/null +++ b/crates/lab-scene/src/scene.rs @@ -0,0 +1,137 @@ +//! `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], + /// 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(), + } + } + + pub fn with_geometry(mut self, geometry: Geometry) -> Self { + self.geometry = Some(geometry); + self + } + + /// 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 = [ + 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 }, + /// 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 +/// 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 }, + /// 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 new file mode 100644 index 0000000..96ae90e --- /dev/null +++ b/crates/lab-scene/src/star.rs @@ -0,0 +1,324 @@ +//! 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, + assets: Option<&crate::assets::AssetCatalog>, +) -> 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(crate::workcell::geometry_for( + assets, + &placement.catalog, + [extent[0], extent[1], 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(crate::workcell::geometry_for( + assets, + resolved.labware.id, + labware_extent, + )); + + // 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, None).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, None).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..fabc46c --- /dev/null +++ b/crates/lab-scene/src/usda.rs @@ -0,0 +1,427 @@ +//! 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, Semantic}; + +/// 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 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, + }; + emitter.node(&scene.root, 0); + emitter.text +} + +/// USD prim names are identifiers; scene ids carry `/`, `:`, `#`. +pub(crate) 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 +} + +/// 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", + 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); 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), + ("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), + ("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> { + context: &'context EmitContext, + root: String, + text: String, +} + +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 + ), + ); + 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, "}"); + } + + 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}\",")); + } + 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); + } + 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 { + self.node(child, depth + 1); + } + self.line(depth, "}"); + } +} + +#[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], + rotation_z_deg: 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" + ); + 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" + ); + } +} diff --git a/crates/lab-scene/src/workcell.rs b/crates/lab-scene/src/workcell.rs new file mode 100644 index 0000000..3204ddb --- /dev/null +++ b/crates/lab-scene/src/workcell.rs @@ -0,0 +1,169 @@ +//! The workcell room scene: stations in a row, each with what geometry it +//! has. The liquid handler nests its full deck; instruments without a +//! 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; + +/// 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. 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 { + "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], + }, + } +} + +/// 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(), + }, + [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); + // 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. + node.children.push(star_deck_scene(profile, assets)?); + } + 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, + assets: Option<&AssetCatalog>, +) -> Result { + workcell_scene( + name, + vec![StationScene { + name: "star".to_string(), + kind: "hamilton.star".to_string(), + star_profile: Some(profile.clone()), + }], + assets, + None, + ) +} diff --git a/docs/README.md b/docs/README.md index ef90042..cdc8d61 100644 --- a/docs/README.md +++ b/docs/README.md @@ -60,6 +60,12 @@ 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 | +| [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 @@ -68,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/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/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/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..09d8564 --- /dev/null +++ b/docs/language/decisions/0035-facility-files.md @@ -0,0 +1,55 @@ +# 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. + +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. +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/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. 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 6d2ca7b..93b6031 100644 --- a/examples/golden-gate/README.md +++ b/examples/golden-gate/README.md @@ -34,20 +34,42 @@ 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 scene # scene.json + glTF + USD per wave (--animated adds the timeline) +lab render # Blender frames and a movie from those outputs, per wave ``` -It writes those under `.lab/build/opentrons-ot2/`, one directory per planning 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. + +The build output holds, per target directory: | Path | Contents | | --- | --- | @@ -123,6 +145,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/facility.toml b/examples/golden-gate/facility.toml new file mode 100644 index 0000000..d6c2d60 --- /dev/null +++ b/examples/golden-gate/facility.toml @@ -0,0 +1,45 @@ +# The lab this package usually runs in: the workcell-star bench plus the +# 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" + +# 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" +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/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 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 }, +] diff --git a/render/lab_blender.py b/render/lab_blender.py new file mode 100644 index 0000000..97a6351 --- /dev/null +++ b/render/lab_blender.py @@ -0,0 +1,487 @@ +# 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("--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) + + +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), + "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), +} + + +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) + 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 + 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", "") + 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"] + 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) + # 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) + 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"): + # 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)) + prefs.keyframe_new_interpolation_type = previous + + 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") + ensure_node_tree(world) + 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 + # 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 + 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 + ) + 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) + + +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: + # 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 + + +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 + bpy.context.preferences.edit.keyframe_new_interpolation_type = "LINEAR" + + 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)) + # 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) + 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() 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..97b2e0d --- /dev/null +++ b/viewer/src/main.ts @@ -0,0 +1,635 @@ +// 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"; +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: "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[]; +} + +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 }), + // 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, + }), + 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; + 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 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(); + +// 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); + 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" || 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( + 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); + } + const isWell = node.semantic.kind === "well"; + const isRoom = node.semantic.kind === "room"; + 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") { + stationMeshes.set(node.id, mesh); + stationHeights.set( + node.id, + node.geometry.shape === "box" + ? node.geometry.z + : node.geometry.shape === "cylinder" + ? node.geometry.height + : node.geometry.fallback[2], + ); + } + } + 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); +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, + 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.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); +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 ---------- + +/** 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); + 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"] +}