diff --git a/.agents/skills/optimize-slurm-topology/SKILL.md b/.agents/skills/optimize-slurm-topology/SKILL.md new file mode 100644 index 00000000..fadaba04 --- /dev/null +++ b/.agents/skills/optimize-slurm-topology/SKILL.md @@ -0,0 +1,111 @@ +--- +name: optimize-slurm-topology +description: Optimize AlpaSim Slurm topology throughput using persistent local Prometheus/Grafana telemetry and run artifacts. Use when tuning service GPU placement, replicas_per_container, runtime.nr_workers, endpoint n_concurrent_rollouts, NRE/physics cache sizes, or Slurm experiment batches for full-duration rollout throughput. +--- + +# Optimize Slurm Topology + +Use this workflow to iteratively improve AlpaSim rollout throughput on Slurm. Optimize for full 20s rollout throughput, not startup-only behavior. + +## Inputs + +When this skill is invoked, the user must specify a full run command as starting +point for the optimization. This command provides a base topology as starting +point, as well as the target configuration (e.g. driver, sceneset, n_rollouts, +cluster, and any other parameters such as number of cameras, simulation +frequency, etc.). + +If the user doesn't specify a full run command, ask them! + +## Telemetry and Memory Setup + +Use one persistent local Prometheus/Grafana instance for the whole experiment. + +1. Start local telemetry once with + `src/tools/scripts/start-prometheus-grafana.sh + --grafana-port 3003 --prometheus-port 9093`. Use the non-default ports to + avoid conflicts with user-started telemetry stacks. +2. The `` argument is provided by the experiment logs. + For example, the default value on IAD is + `:/lustre/fsw/portfolios/av/projects/av_alpamayo_reasoning/data/av_alpamayo_sim/.cache/prometheus/file-sd` +3. Keep the local telemetry stack running until all experiment candidates have + been evaluated. Then stop it with `src/tools/scripts/start-prometheus-grafana.sh stop` +4. Create a repo-local experiment record from `references/experiment-record.md`. + Default path: + `docs/experiments/topology-opt---.md`. This file will be your experiment log and memory. It should contain all necessary information to understand why a topology was tried and what the results were. + +## Experiment Loop + +0. Start from a known topology and run a baseline experiment. +1. If you already have a baseline, start one or multiple candidate experiments + in parallel (at most 3). +2. The experiments have an initial startup time of a couple of about 5 min + before they appear in Prometheus. After that, they start producing rollouts. + However, because all rollouts are initially started simultenously, there's + significant congestion in the first 10-15 minutes. Wait until you can see + this congestion has cleared and the system has reached a steady state. Use + the 5m `seconds_per_rollout` only as an early diagnostic. Once the full-run + `seconds_per_rollout` has stabilized, typically after 30-45 minutes, use it + as the primary optimization target. +3. Reject candidates that OOM or crash. Analyze the reason for failure and avoid + repeating the same mistake. A typical reason is insufficient GPU memory. +4. Once a candidate reaches steady state, analyze it carefully and document (see + `references/metrics.md` and `references/topology-knobs.md`): + * Its stabilized full-run `seconds_per_rollout`, using the 5m value only to + diagnose recent behavior and confirm that the run remains healthy. + * Its bottlenecks, using + `alpasim:rpc_queue_depth_at_start_latest:max` as the primary bottleneck + signal and `alpasim:rpc_queue_depth_at_start_latest:min` to detect workers + that are starving or receiving uneven load. + * Its used and available resources, including per-GPU utilization, memory + consumption, memory pressure, and memory headroom. + * Opportunities for improvement. +5. Skill improvement reflections: Did you learn something new about the system + that was not yet covered in the skill? This can include, for example: + * How to run experiments or query results. + * Which Prometheus queries are useful. + * How the topology knobs affect throughput and memory. + * Are there additional metrics that we should introduce to better understand + the system? + * Any additional scripts that you wrote to help with the experiments that + would be useful to add to the skill. + * Or anything else that you think is useful to remember for future + experiments. +5. Keep two record sections current (see `references/experiment-record.md`). + These records should include the output of both step 4 and step 5, and should + be updated after every candidate experiment: + - a short Markdown progress document (including table) for humans and quick + parsing; + - a more detailed JSON memory block with fixed inputs, runs, metrics, GPU + utilization, GPU memory consumption and headroom, decisions, and links to + run artifacts. +6. Decide on the next topology changes and go back to step 1. +7. You should stop running experiments as soon as you have enough data to + support your reasoning and decision. It is not required to let them run until + the end. However, do not compare or stop a healthy candidate before its + full-run `seconds_per_rollout` has stabilized, normally 30-45 minutes after + rollout production begins. Note that "steady state" can still contain cyclic + behavior. +8. Stop when you can't make progress over multiple iterations or when you don't + believe there is more enough free resources to improve throughput. + +## Supporting documents + +* Read `references/experiment-record.md` for how to keep a record of the experiment and its candidates. +* Read `references/metrics.md` for current metric names, PromQL queries, and interpretation. +* Read `references/topology-knobs.md` for guidelines on how to change topology and what to expect from each change. + +## Final Reporting + +At the end of the optimization, re-read the experiment record and summarize the +results in a DETAILED final report, including: + +1. Baseline topology and initial speed (primary stabilized full-run + `seconds_per_rollout`, plus 5m `seconds_per_rollout` for recent-behavior + context), including per-GPU utilization and memory consumption/headroom. +2. All tried topologies, their reasoning, expected effect, measured result, + resource usage, and decision. +3. Best topology found, why it won, remaining bottlenecks or constraints, and + how much GPU utilization and memory headroom remains for further tuning. +4. Any advice on how the skill or the instructions can be improved. +5. Link the repo-local experiment record. diff --git a/.agents/skills/optimize-slurm-topology/agents/openai.yaml b/.agents/skills/optimize-slurm-topology/agents/openai.yaml new file mode 100644 index 00000000..0e1eb575 --- /dev/null +++ b/.agents/skills/optimize-slurm-topology/agents/openai.yaml @@ -0,0 +1,13 @@ +interface: + display_name: "Optimize Slurm Topology" + short_description: "Tune AlpaSim Slurm topology throughput." + default_prompt: | + Optimize an AlpaSim Slurm topology using persistent local telemetry. + Treat `alpasim:simulation_seconds_per_rollout:avg` as the primary + throughput target once it has stabilized, typically after 30-45 minutes. + Use `alpasim:simulation_seconds_per_rollout:rate5m` only as an early and + recent-behavior diagnostic. + Use the Grafana RPC latency distribution heatmaps and recording rules + `alpasim:driver_drive_rpc_duration_seconds_bucket:rate1m` and + `alpasim:nre_render_rpc_duration_seconds_bucket:rate1m` to distinguish + driver/NRE tail spikes from broad service latency shifts. diff --git a/.agents/skills/optimize-slurm-topology/references/experiment-record.md b/.agents/skills/optimize-slurm-topology/references/experiment-record.md new file mode 100644 index 00000000..babd68e0 --- /dev/null +++ b/.agents/skills/optimize-slurm-topology/references/experiment-record.md @@ -0,0 +1,162 @@ +# Experiment Record Template + +This file contains templates for both a markdown experiment record and a JSON run memory. The experiment record is intended to be human-readable and editable, while the run memory is intended to be machine-readable and used to resume the optimization process. + +Generate these files in the repo for each optimization pass. Default path: + +`docs/experiments/topology-opt---.md` +`docs/experiments/topology-opt---.json` + +## Json Record + +The top-level structure of the JSON record is as follows: + +```json +{ + "experiment_id": "topology-opt---", + "objective": "minimize stabilized full-run seconds_per_rollout", + "fixed_inputs": { + "deploy": "", + "driver": "", + "base_topology": "topology=", + "scenes": { + "scene_ids": null, + "test_suite_id": "public_2601" + }, + "simulation_duration_s": 20, + "rollouts_per_scene": 1, + "git_commit": "", + "remote_checkout": "", + "telemetry": { + "prometheus_url": "http://localhost:", + "grafana_url": "http://localhost:", + "file_sd_source": "" + }, + "slurm": { + "account": "", + "partition": "", + "gpus_per_node": 8, + "walltime": "04:00:00" + } + }, + "current_best": { + "topology": null, + "stabilized_seconds_per_rollout_full_run": null, + "decision_reasoning": null + }, + "runs": [] +} +``` + +The run entries in the `runs` array have the following structure: + +```json +{ + "runs": [ + { + "name": "", + "job_id": "", + "run_dir": "", + "topology": "topology=", + "status": "pending|running|accepted|rejected|failed|stopped", + "hypothesis": "", + "change": { + "hydra_or_yaml_path": " new>" + }, + "stable_metrics": { + "warmup_excluded_s": null, + "completed_rollouts": null, + "seconds_per_rollout_5m": null, + "seconds_per_rollout_full_run": null, + "rollout_duration_p95_s": null, + "step_duration_p95_s": null, + "rpc_queue_depth_at_start_latest_by_service": { + "sensorsim": {"worker_min": null, "worker_max": null}, + "driver": {"worker_min": null, "worker_max": null}, + "physics": {"worker_min": null, "worker_max": null}, + "controller": {"worker_min": null, "worker_max": null} + }, + "rpc_duration_p95_s_by_method": { + "run_controller_and_vehicle": null, + "drive": null, + "submit_egomotion_observation": null, + "submit_image_observation": null, + "submit_route": null, + "ground_intersection": null, + "render_rgb": null + }, + "runtime_idle_fraction": null, + "resource_usage": { + "process_cpu_utilization_percent_by_group": { + "": {"min": null, "mean": null, "max": null} + }, + "gpu_by_index": { + "": { + "topology_services": [], + "utilization_percent": { + "min": null, + "mean": null, + "max": null + }, + "memory_used_gb": { + "min": null, + "mean": null, + "max": null + }, + "memory_total_gb": null, + "memory_pressure_percent": { + "mean": null, + "max": null + }, + "memory_headroom_gb_min": null + } + }, + "host_memory": { + "available_gb_min": null, + "total_gb": null + } + }, + "bottleneck": { + "service": null, + "evidence": [], + "confidence": "low|medium|high" + }, + "telemetry_quality": { + "missing_metrics": [], + "gaps": [], + "notes": null + } + }, + "failures": [], + "decision": { + "outcome": "pending|accept|reject", + "reason": null, + "next": null + }, + "skill_reflections": [ + { + "topic": "metrics|queries|topology_knobs|run_workflow|failure_mode|other", + "learning": null, + "suggested_skill_update": null + } + ] + } + ] +} +``` + + +## Markdown Record + +Keep a markdown record of the experiment in the repo for human readability. It +should contain a separate entry for each candidate. This entry should include +important findings and decisions on what to try next and why. Also include some +key metrics, including rollouts_per_second, queue-depth (min and max), GPU +utilization and memory and which services are on this GPU and if there is more +headroom (and why it wasn't used). + +At the top of the file, also include a table. + +| Step | Job | Topology | Change | Observation | Decision | Next | Skill update | +|---:|---|---|---|---|---|---|---| +| 1 | `` | `topology=` | baseline | `` | baseline | `` | `` | diff --git a/.agents/skills/optimize-slurm-topology/references/metrics.md b/.agents/skills/optimize-slurm-topology/references/metrics.md new file mode 100644 index 00000000..bcf9854a --- /dev/null +++ b/.agents/skills/optimize-slurm-topology/references/metrics.md @@ -0,0 +1,266 @@ +# Metrics Reference + +## Source Of Truth + +1. Raw runtime metrics are defined in + `src/runtime/alpasim_runtime/telemetry/telemetry_context.py`. +2. Slurm process metrics are defined in + `src/wizard/alpasim_wizard/telemetry/slurm_process_exporter.py`. +3. Prometheus scrape targets and rule installation are generated in + `src/wizard/alpasim_wizard/telemetry/prometheus.py`. +4. Recording rules live in + `src/utils/alpasim_utils/telemetry/metrics_plot_recording_rules.yml`. +5. The wizard copies those rules into each run as + `/prometheus/rules/alpasim-recording-rules.yml`. +6. Prometheus loads copied rules through generated + `/prometheus/prometheus.yml`. + +Prometheus file-SD targets are written to +`/prometheus/targets/alpasim.json`. Central file-SD publication uses +`wizard.telemetry.file_sd_dir` when configured. + +## Query Surface + +Use labels such as `run_uuid`, `run_name`, `node`, `user`, and +`slurm_job_id` to separate candidates. Scrape jobs are: + +1. `alpasim-runtime-worker`: per-worker AlpaSim runtime metrics. +2. `alpasim-node`: node exporter metrics. +3. `alpasim-process`: process CPU and memory metrics. +4. `alpasim-dcgm`: DCGM GPU metrics, when `dcgm-exporter` is available. + +## Interpretation Rules + +1. Lower stabilized full-run `seconds_per_rollout` is the primary optimization + target. It normally becomes useful after 30-45 minutes of rollout production. +2. Use the 5m `seconds_per_rollout` only as an early and recent-behavior + diagnostic. Compare candidates only after startup congestion clears and the + full-run metric stops materially drifting. Confirm stabilization across + multiple observations; elapsed time alone is insufficient. +3. High `alpasim:rpc_queue_depth_at_start_latest:max` identifies the bottleneck + and the service to scale or isolate. This gauge is set directly to the + observed depth and does not saturate at 50. +4. Low `alpasim:rpc_queue_depth_at_start_latest:min` means at least one worker + may be starving. Low min and high max might indicate some load imbalance (either across workers or time). Low min and only slighlty higher max indicates that the overall capacity should be increased to avoid service starvation. +5. High RPC blocking p95 and low runtime idle points to runtime/event-loop contention. +6. GPU memory above 90% is a hard constraint signal. +7. Low GPU utilization with memory headroom suggests more load or co-location. +8. High per-process CPU means process-level scaling can help even if node CPU average looks fine. + +## How to find the bottleneck + +Use the primary throughput metric to compare candidates. Use supporting signals +(see `topology-knobs.md`), e.g. latest queue depth, RPC p95, runtime idle, CPU, +GPU util, and GPU memory only to explain the bottleneck and choose the next +topology change. + +## Prometheus-Attached Labels + +The wizard attaches these labels to every scrape target in file-SD: + +| Label | Value | Meaning | +|---|---|---| +| `run_uuid` | `run_metadata["run_uuid"]` | Stable identity for one run. | +| `run_name` | `run_metadata["run_name"]` | Human-readable run name. | +| `user` | `$USER` or `unknownUser` | User who launched the run. | +| `node` | `socket.gethostname()` | Node that generated the telemetry config. | +| `slurm_job_id` | `cfg.wizard.slurm_job_id` or empty string | Slurm job ID when running under Slurm. | +| `job` | one of the scrape jobs above | Target type: runtime worker, node, process, or DCGM. | + +Prometheus also attaches `instance=:` from the scrape target. The +metric name is available as pseudo-label `__name__` in queries. Histogram bucket +series also include `le`; histogram `_sum` and `_count` series do not. + +## Raw AlpaSim Metrics + +Histogram metrics expose `_bucket`, `_sum`, and `_count` series. The labels in +this table are metric-emitted labels; raw scraped series also carry the +Prometheus-attached labels above. Use the recording rules below instead of raw +histogram math unless an ad-hoc query needs the raw distribution. + +| Metric family | Type | Labels | Measures | Interpret | +|---|---|---|---|---| +| `alpasim_rpc_duration_seconds` | histogram | `service`, `method`, `tag`, `worker_id` | End-to-end RPC call duration. | High p95 means a service method is slow or queued internally. Check with queue depth and blocking. | +| `alpasim_rpc_blocking_seconds` | histogram | `service`, `method`, `tag`, `worker_id` | Time between gRPC I/O completion and coroutine resumption. | High values mean the worker event loop is not resuming promptly, often due Python/runtime contention. | +| `alpasim_rpc_queue_depth_at_start_latest` | gauge | `service`, `tag`, `worker_id` | Latest sampled in-flight RPC count when an RPC starts. | Primary queue signal for bottleneck and capacity decisions. It is set to the exact observed depth and has no bucket ceiling. A worker's value persists until its next RPC sample. | +| `alpasim_rollout_duration_seconds` | histogram | `worker_id` | Full rollout wall time. | High p95 shows tail rollout latency; compare across workers for imbalance. | +| `alpasim_step_duration_seconds` | histogram | `worker_id` | Per-step wall time. | High p95 shows step-level latency.| +| `alpasim_simulation_total_seconds` | gauge | `worker_id` | Total elapsed simulation time accumulated by a worker. | Input for 5m and full-run seconds per rollout. | +| `alpasim_simulation_rollout_count` | gauge | `worker_id` | Completed rollout count. | Used as the 5m denominator. Must increase before judging steady state. | +| `alpasim_simulation_seconds_per_rollout` | gauge | `worker_id` | Full-run average seconds per completed rollout. | Primary optimization input once its run-level average has stabilized. Includes startup/congestion, so allow 30-45 minutes and verify that drift has subsided. | +| `alpasim_event_loop_idle_seconds_total` | gauge | `worker_id` | Time the event loop spent waiting for I/O. | Used with poll/work time to estimate runtime idle fraction. High idle usually means workers wait on services. | +| `alpasim_event_loop_poll_seconds_total` | gauge | `worker_id` | Time spent checking non-blocking I/O. | Large values can indicate event-loop overhead. Interpret with idle/work. | +| `alpasim_event_loop_work_seconds_total` | gauge | `worker_id` | Time spent executing Python work. | High work fraction can mean runtime CPU contention. | +| `alpasim_gc_total_duration_seconds` | gauge | `worker_id` | Total time spent in garbage collection. | High values indicate GC pressure may affect runtime progress. | +| `alpasim_gc_max_duration_seconds` | gauge | `worker_id` | Longest observed GC pause. | Large pauses can explain tail latency spikes. | +| `alpasim_gc_collection_count_total` | gauge | `worker_id` | Number of GC collections. | Use with GC duration; count alone is not a bottleneck signal. | + +RPC recording rules currently focus on these methods: +`run_controller_and_vehicle`, `drive`, `submit_egomotion_observation`, +`submit_image_observation`, `submit_route`, `ground_intersection`, and +`render_rgb`. + +## External Metrics Used + +Prometheus scrapes all metrics exposed by node exporter, process exporter, and +DCGM exporter. The repo explicitly uses the following external metric names in +recording rules or dashboards: + +Exporter binaries may expose additional raw metrics that are not enumerated in +this repo. For a live run, query Prometheus +`/api/v1/label/__name__/values` or open the exporter `/metrics` endpoint if +those raw exporter internals matter. + +| Metric | Source job | Labels | Measures | Interpret | +|---|---|---|---|---| +| `namedprocess_namegroup_cpu_seconds_total` | `alpasim-process` | `groupname` | Cumulative CPU seconds per process group. | Rate converts to CPU utilization percent. High per-group CPU can motivate more replicas, lower concurrency, or more CPU. | +| `namedprocess_namegroup_memory_bytes` | `alpasim-process` | `groupname`, `memtype` | Process memory by process group. | Use resident memory to identify host memory pressure or unexpected process growth. | +| `alpasim_slurm_process_exporter_scrape_duration_seconds` | `alpasim-process` | none | Time spent collecting Slurm process metrics. | High values mean process metrics may be stale or expensive to collect. | +| `DCGM_FI_DEV_GPU_UTIL` | `alpasim-dcgm` | `gpu` | GPU utilization percent by GPU. | Low utilization with memory headroom suggests more load or co-location; high utilization plus queues suggests GPU capacity pressure. | +| `DCGM_FI_DEV_FB_TOTAL` | `alpasim-dcgm` | `gpu` | Total physical GPU framebuffer memory in MiB. | Static GPU capacity; use this instead of summing independently sampled used, free, and reserved gauges. | +| `DCGM_FI_DEV_FB_USED` | `alpasim-dcgm` | `gpu` | GPU framebuffer memory used in MiB. | Above 90% is a hard constraint signal. Reduce cache, concurrency, replicas, or co-location. | +| `DCGM_FI_DEV_FB_FREE` | `alpasim-dcgm` | `gpu` | Free GPU framebuffer memory in MiB. | Use directly as allocatable memory headroom; add used and reserved memory to derive physical total memory. | +| `DCGM_FI_DEV_FB_RESERVED` | `alpasim-dcgm` | `gpu` | Driver-reserved GPU framebuffer memory in MiB. | Add to used and free memory to derive total physical framebuffer memory. | +| `node_cpu_seconds_total` | `alpasim-node` | `mode` | Node CPU seconds by mode. | Dashboard converts idle rate to node CPU utilization. If node CPU is saturated, service scaling may not help. | +| `node_memory_MemAvailable_bytes` | `alpasim-node` | none | Available host memory. | Low available memory indicates node memory pressure. | +| `node_memory_MemTotal_bytes` | `alpasim-node` | none | Total host memory. | Used with available memory to compute host memory utilization. | + +Process groups are configured as `runtime`, `driver`, `renderer`, `physics`, +`trafficsim`, and `controller`. + +The labels in the external metrics table are exporter-emitted labels. Scraped +series also carry the Prometheus-attached labels above. + +## Recording Rules + +All rules are in group `alpasim_metrics_plot` in +`src/utils/alpasim_utils/telemetry/metrics_plot_recording_rules.yml`. + +| Rule | Output labels | Expression summary | Measures | Interpret | +|---|---|---|---|---| +| `alpasim:rpc_duration_seconds_bucket:sum` | `run_uuid`, `run_name`, `method`, `le` | Sum raw RPC duration buckets by run, method, and bucket. | Aggregated RPC duration histogram. | Base input for RPC duration p95; use when inspecting distributions. | +| `alpasim:rpc_blocking_seconds_bucket:sum` | `run_uuid`, `run_name`, `method`, `le` | Sum raw RPC blocking buckets by run, method, and bucket. | Aggregated event-loop blocking histogram after RPC I/O completion. | Base input for blocking p95; high tails point to runtime scheduling contention. | +| `alpasim:rpc_queue_depth_at_start_latest:max` | `run_uuid`, `run_name`, `service` | Max current latest queue-depth-at-start gauge across worker series by run and service. | Exact latest queue-depth samples, aggregated across workers. | Primary bottleneck locator for service scaling or isolation. It has no 50-value ceiling, but is not a historical window maximum. | +| `alpasim:rpc_queue_depth_at_start_latest:min` | `run_uuid`, `run_name`, `service` | Min current latest queue-depth-at-start gauge across worker series by run and service. | Lowest exact latest queue-depth sample across workers. | Worker starvation and load-imbalance signal. A persistent low min with high max means workers are fed unevenly. | +| `alpasim:rollout_duration_seconds_bucket:sum` | `run_uuid`, `run_name`, `le` | Sum raw rollout duration buckets by run and bucket. | Aggregated rollout duration histogram. | Base input for rollout p95; use to inspect tail shape. | +| `alpasim:step_duration_seconds_bucket:sum` | `run_uuid`, `run_name`, `le` | Sum raw step duration buckets by run and bucket. | Aggregated step duration histogram. | Base input for step p95; use when per-step latency dominates. | +| `alpasim:driver_drive_rpc_duration_seconds_bucket:sum` | `run_uuid`, `run_name`, `le` | Sum raw driver `drive` duration buckets by run and bucket. | Driver `drive` latency distribution. | Base input for driver latency distribution panels. | +| `alpasim:driver_drive_rpc_duration_seconds_bucket:rate1m` | `run_uuid`, `run_name`, `le` | 1m rate of raw driver `drive` duration buckets by run and bucket. | Moving driver `drive` latency distribution. | Use in Grafana heatmaps to see the current driver tail shape. | +| `alpasim:nre_render_rpc_duration_seconds_bucket:sum` | `run_uuid`, `run_name`, `method`, `le` | Sum raw sensorsim render duration buckets for `render_rgb`, `batch_render_rgb`, and `render_aggregated`. | NRE render latency distribution by method. | Base input for NRE latency distribution panels. | +| `alpasim:nre_render_rpc_duration_seconds_bucket:rate1m` | `run_uuid`, `run_name`, `method`, `le` | 1m rate of sensorsim render duration buckets for `render_rgb`, `batch_render_rgb`, and `render_aggregated`. | Moving NRE render latency distribution by method. | Use in Grafana heatmaps; select one method at a time with `nre_render_method`. | +| `alpasim:rpc_duration_seconds:p95` | `run_uuid`, `run_name`, `method` | p95 over `rate(alpasim:rpc_duration_seconds_bucket:sum[1m])` by method. | RPC method tail latency. | High method p95 confirms a slow service call. Pair with queue depth. | +| `alpasim:rpc_blocking_seconds:p95` | `run_uuid`, `run_name`, `method` | p95 over `rate(alpasim:rpc_blocking_seconds_bucket:sum[1m])` by method. | RPC blocking tail latency. | High values indicate runtime event-loop contention rather than only remote service latency. | +| `alpasim:rollout_duration_seconds:p95` | `run_uuid`, `run_name` | p95 over aggregated rollout duration buckets. | Run-level rollout tail latency. | High values mean unstable or slow full rollouts. | +| `alpasim:rollout_duration_seconds:p95_by_worker` | `run_uuid`, `run_name`, `worker_id` | p95 over raw rollout duration buckets by worker. | Per-worker rollout tail latency. | Divergence across workers suggests imbalance, stragglers, or worker-specific faults. | +| `alpasim:step_duration_seconds:p95` | `run_uuid`, `run_name` | p95 over aggregated step duration buckets. | Step tail latency. | Useful when rollout latency is high but queue/RPC evidence is ambiguous. | +| `alpasim:driver_drive_rpc_duration_seconds:p95` | `run_uuid`, `run_name` | p95 over `alpasim:driver_drive_rpc_duration_seconds_bucket:rate1m`. | Driver `drive` tail latency. | Use with driver queue depth and driver heatmap to decide whether driver capacity is the bottleneck. | +| `alpasim:nre_render_rpc_duration_seconds:p95` | `run_uuid`, `run_name`, `method` | p95 over `alpasim:nre_render_rpc_duration_seconds_bucket:rate1m`. | NRE render tail latency by method. | Use with sensorsim queue depth and NRE heatmap to decide whether render capacity/cache is the bottleneck. | +| `alpasim:event_loop_idle_fraction:ratio` | `run_uuid`, `run_name` | idle / (idle + poll + work), summed by run. | Runtime event-loop idle fraction. | Very high idle means workers are waiting on services. Low idle with CPU headroom suggests increasing `runtime.nr_workers` may help. | +| `alpasim:simulation_seconds_per_rollout:rate5m` | `run_uuid`, `run_name` | `increase(alpasim_simulation_total_seconds[5m]) / increase(alpasim_simulation_rollout_count[5m])`, summed by run. | Moving 5m seconds per rollout. | Early and recent-behavior diagnostic. Do not use noisy short-window differences to rank stabilized candidates. | +| `alpasim:simulation_seconds_per_rollout:avg` | `run_uuid`, `run_name` | Average raw worker `alpasim_simulation_seconds_per_rollout` by run. | Full-run average seconds per rollout. | Primary optimization metric after it stops materially drifting, typically after 30-45 minutes of rollout production. Lower is better. | +| `alpasim:driver_drive_rpc_duration_seconds_sum:sum` | `run_uuid`, `run_name` | Sum raw driver `drive` RPC duration sums by run. | Total driver `drive` RPC seconds. | Divide by count for mean driver drive latency. | +| `alpasim:driver_drive_rpc_duration_seconds_count:sum` | `run_uuid`, `run_name` | Sum raw driver `drive` RPC counts by run. | Number of driver `drive` RPC observations. | Use as denominator; low count means weak latency evidence. | +| `alpasim:process_cpu_utilization_percent:rate30s` | `run_uuid`, `run_name`, `groupname` | `100 * rate(namedprocess_namegroup_cpu_seconds_total[30s])` by process group. | Process-group CPU utilization percent. | High group CPU means that process is CPU-bound or needs different replica/concurrency placement. | +| `alpasim:process_cpu_utilization_percent:max_by_group:rate30s` | `run_uuid`, `run_name`, `groupname` | Maximum per-process CPU utilization within each process group. | Hottest process CPU utilization percent. | Use with group totals to identify a saturated process hidden by aggregate CPU. | +| `alpasim:gpu_utilization_percent:avg` | `run_uuid`, `run_name`, `gpu` | Average `DCGM_FI_DEV_GPU_UTIL` by GPU. | GPU utilization percent. | Low utilization with memory headroom suggests underuse; high utilization with queues suggests GPU pressure. | +| `alpasim:gpu_memory_gb:avg` | `run_uuid`, `run_name`, `gpu` | Average `DCGM_FI_DEV_FB_USED / 1024` by GPU. | GPU memory used in GiB. | High memory limits cache/concurrency/co-location changes. | +| `alpasim:gpu_memory_total_gb:avg` | `run_uuid`, `run_name`, `gpu` | Average `DCGM_FI_DEV_FB_TOTAL / 1024` by GPU. | Total physical GPU memory in GiB. | Subtract used and reserved memory to compute available headroom. | +| `alpasim:gpu_memory_pressure_percent:avg` | `run_uuid`, `run_name`, `node`, `gpu` | `100 * used / (used + free)` by GPU. | GPU memory pressure percent. | Above 90% is a hard constraint; report both pressure and remaining GiB. | + +## Common Queries + +Primary throughput after stabilization: + +```promql +alpasim:simulation_seconds_per_rollout:avg +``` + +Early and recent-behavior diagnostic: + +```promql +alpasim:simulation_seconds_per_rollout:rate5m +``` + +Queue bottleneck and worker starvation by service (exact gauge values with no +50-value ceiling): + +```promql +alpasim:rpc_queue_depth_at_start_latest:max +alpasim:rpc_queue_depth_at_start_latest:min +``` + +Raw fallback when the recording rule is unavailable: + +```promql +max by (run_uuid, run_name, service) ( + alpasim_rpc_queue_depth_at_start_latest +) +min by (run_uuid, run_name, service) ( + alpasim_rpc_queue_depth_at_start_latest +) +``` + +RPC latency by method: + +```promql +alpasim:rpc_duration_seconds:p95 +``` + +Driver and NRE latency distributions: + +```promql +alpasim:driver_drive_rpc_duration_seconds_bucket:rate1m +alpasim:nre_render_rpc_duration_seconds_bucket:rate1m +``` + +Driver and NRE tail latency: + +```promql +alpasim:driver_drive_rpc_duration_seconds:p95 +alpasim:nre_render_rpc_duration_seconds:p95 +``` + +RPC blocking by method: + +```promql +alpasim:rpc_blocking_seconds:p95 +``` + +Runtime idle: + +```promql +alpasim:event_loop_idle_fraction:ratio +``` + +Process CPU: + +```promql +alpasim:process_cpu_utilization_percent:rate30s +``` + +GPU utilization and memory: + +```promql +alpasim:gpu_utilization_percent:avg +alpasim:gpu_memory_gb:avg +alpasim:gpu_memory_total_gb:avg +alpasim:gpu_memory_pressure_percent:avg +``` + +Use `min_over_time`, `avg_over_time`, and `max_over_time` over the same stable +window used for throughput. For example: + +```promql +min_over_time(alpasim:gpu_utilization_percent:avg[5m]) +avg_over_time(alpasim:gpu_utilization_percent:avg[5m]) +max_over_time(alpasim:gpu_utilization_percent:avg[5m]) + +min_over_time(alpasim:gpu_memory_gb:avg[5m]) +avg_over_time(alpasim:gpu_memory_gb:avg[5m]) +max_over_time(alpasim:gpu_memory_gb:avg[5m]) + +max_over_time(alpasim:gpu_memory_pressure_percent:avg[5m]) +min_over_time( + (DCGM_FI_DEV_FB_FREE / 1024)[5m:] +) +``` diff --git a/.agents/skills/optimize-slurm-topology/references/topology-knobs.md b/.agents/skills/optimize-slurm-topology/references/topology-knobs.md new file mode 100644 index 00000000..cf2d5332 --- /dev/null +++ b/.agents/skills/optimize-slurm-topology/references/topology-knobs.md @@ -0,0 +1,220 @@ +# Topology Knobs + +## Stable Window + +Evaluate each candidate once it has enough completed full 20s rollouts for a +stable comparison: In the beginning, all rollouts start simultaneoulsy and the +data isn't representative. Monitor +`alpasim:simulation_seconds_per_rollout:rate5m` only for early and recent +behavior. Use `alpasim:simulation_seconds_per_rollout:avg` as the primary +comparison metric once it stops materially drifting across multiple +observations, typically after 30-45 minutes of rollout production. Do not infer +stability from elapsed time alone. + +## The Topology Configuration + +During optimizations, only change the configuration listed here. Do not change +other simulation parameters (such as frequencies, driver configuration, etc..) + +### Capacity + +AlpaSim simulates multiple rollouts in parallel. The exact number is giving by +its overall capacity, which is the minimum capacity across all services. The +capacity of each service is determined by the number of instances of that +service (which is the number of entries in the `gpus` list field), and the +number of concurrent rollouts each instance can handle. The number of instances +is determined by the number of containers of that service, multiplied by the +number of replicas per container. + +In short: + +```text +capacity = nr_containers * replicas_per_container * n_concurrent_rollouts +``` + +See, for example, the following configuration: + +```yaml +defines: + nre_cache_size: 17 # renderer.n_concurrent_rollouts + 1 + physics_cache_size: 6 + +eval: + num_processes: 32 + +services: + renderer: + environments: + - HOME=/tmp + - XDG_CACHE_HOME=/tmp/.cache + - OMP_NUM_THREADS=1 + - PYTORCH_CUDA_ALLOC_CONF=garbage_collection_threshold:0.7 + replicas_per_container: 1 + gpus: [0, 1, 2, 3] + + driver: + replicas_per_container: 8 + gpus: [4, 5, 6, 7] + + physics: + replicas_per_container: 4 + gpus: [4, 5, 6, 7] + + controller: + replicas_per_container: 16 + gpus: null + + trafficsim: + replicas_per_container: 1 + gpus: [0, 1] + +runtime: + nr_workers: 8 + endpoints: + # Total capacity per service = nr_gpus x replicas_per_container x n_concurrent_rollouts + # REND: 4 x 1 x 16 = 64 + # DRIV: 4 x 8 x 2 = 64 + # PHYS: 4 x 4 x 4 = 64 + # CONT: 1 (CPU) x 16 x 4 = 64 + renderer: + n_concurrent_rollouts: 16 + driver: + n_concurrent_rollouts: 2 + skip: false + physics: + n_concurrent_rollouts: 4 + skip: false + controller: + n_concurrent_rollouts: 4 + trafficsim: + skip: true +``` + +Here, `renderer` has 4 containers (`gpus: [0, 1, 2, 3]`) with 1 replica each and +can handle 16 concurrent rollouts per container, giving it a total capacity of +64. The `driver` service has 4 containers (gpus: [4, 5, 6, 7]) with 8 replicas +each and can handle 2 concurrent rollouts per container, also giving it a total +capacity of 64. The `physics` service has the same GPU allocation as `driver`, +but with 4 replicas and can handle 4 concurrent rollouts per container, again +giving it a total capacity of 64. The `controller` service is CPU-bound with a +single container and can handle 16 replicas with 4 concurrent rollouts, also +resulting in a total capacity of 64. The `trafficsim` service is skipped in this +configuration. + +Generally, it makes sense if the capacity of each service is approximately +equal, as otherwise it can lead to wasted resources. For example, if one service +has a much higher capacity than others, the additional capacity will not be +utilized. + +If `skip: true` is set for a service, it will not be used in the rollout and can +be completely ignored. Do not change this value yourself, but use the default +configuration you got in the beginning. + +### Runtime Workers + +Rollouts are managed by the runtime, which is a CPU intensive process. To avoid +the runtime being the bottleneck, the load is spread across multiple runtime +workers, configured by `runtime.nr_workers`. The total number of rollouts (i.e. +the max capacity) is spread evenly across the runtime workers, so each worker +handles `capacity / nr_workers` rollouts. The runtime workers are also +responsible for managing the service queues and RPCs, so if the runtime is +saturated, it can lead to increased queue times and reduced throughput. +This shows up as a low, i.e. <60%, `alpasim:event_loop_idle_fraction:ratio` metric.) + +### Cache sizes + +There are two important caches that can be tuned: the NRE cache and the physics cache: +* `defines.nre_cache_size` for NRE +* `defines.physics_cache_size` for physics + +These values are important because: +* Loading a scene into the NRE cache takes about 10s, so if the cache is too + small, it can dominate rollout time. +* Choosing a cache size that is too large can lead to GPU memory exhaustion, + which can cause rollouts to fail. + +So these values, especially for NRE, have to be precisely tuned to the minimum +required size. A good starting point for both services is +`(n_concurrent_rollouts + 1)`. + +## Decision Rules + +* IMPORTANT: The capacity of each service should approximately match as otherwise it's + wasted. +* For NRE-backed renderer deployments, multiple replicas per container are not + supported. Use repeated GPU entries when more than one renderer container + should share a GPU, for example `gpus: [0, 0, 1, 1]`. + +### Finding the bottleneck service + +Use `alpasim:rpc_queue_depth_at_start_latest:max` for bottleneck decisions and +`alpasim:rpc_queue_depth_at_start_latest:min` to detect worker starvation. High +max for one service means this service is likely the bottleneck and the current +capacity should be spread out across more processes. Persistent low min with +high max indicates uneven load across runtime workers or across time; low min and max suggests +insufficient offered load or an upstream bottleneck. + +If you find cyclic patterns that are detrimental, this cannot be changed by the config alone - surface this to the user. + +These metrics are derived from an exact gauge. + +* Adding more containers (to the same or other GPUs) or increasing + `replicas_per_container` (if the service supports it) +* Decreasing `n_concurrent_rollouts` s.t. the overall capacity stays + approximately the same. + +### Determining the overall capacity + +#### When should overall capacity be reduced? + +If `alpasim:rpc_queue_depth_at_start_latest:max` is high for multiple services +(typically both renderer and driver), the overall capacity is likely +unnecessarily high. Reducing it can reduce GPU memory pressure by reducing the +number of concurrent rollouts (which allows smaller cache sizes) or the number +of instances of each service. + +#### When should the overall capacity be incrased? + +We want the minimum overall capacity that allows all services to run at full speed without their latest queue depth being empty. + +* If queues of services are empty because the rollouts are piling up and waiting at another service, the solution is not to increase the overall capacity, but to increase the capacity of the bottleneck service. +* If, however, there is no clear bottleneck and + `alpasim:rpc_queue_depth_at_start_latest:min` shows some workers repeatedly + near empty (i.e. below 3-5 waiting rollouts), the overall capacity should be + increased to keep all workers utilized. Check that max is also low first; + low min with high max indicates imbalance rather than insufficient capacity. + +Changing the overall capacity means changing the capacity of all services simultaneously. + +### GPU utilization and memory + +Broadly, the level of GPU utilization tells us how optimized the topology is. +On the other hand, GPU memory is our main limiting factor. + +For every stable run, record utilization min/mean/max, memory used +min/mean/max, total memory, peak memory pressure, and minimum memory headroom +for each GPU. Include the services placed on each GPU so resource pressure can +be attributed to topology changes. + +We can improve GPU utilization by adding more instances of bottlenecking services to that GPU. +If we hit the limit of 90% GPU memory, we need to reduce the number of instances of services on that GPU or reduce the cache size of those services (which can be done by reducing overall capacity or by spreading the rollouts across more instances). + +Low queue depth with low GPU utilization and ample memory headroom indicates +over-capacity. High queue depth with low GPU utilization and memory headroom +suggests adding or splitting service instances. High utilization with high +queue depth indicates compute pressure; high memory pressure is a hard limit +even when utilization is low. + +### CPU utilization + +The CPU can also be a limiting factor for many services, especially controller and driver. +If this is the case, we can try to spread the number of rollouts across more instances of that service, but this might cost additional GPU usage. +If CPU is the bottleneck, this should also be surfaced explicitly to the user, as they might want to optimize the code as well. + +### Runtime idleness + +Runtime idle should usually stay high enough to accept service results. If it is +too low, i.e. lower than 60%, and CPUs have headroom, increase +`runtime.nr_workers`. Do not increase `runtime.nr_workers` without bound. Aim +for roughly 70-80% runtime idle; if idle is already above 80%, look for service +queue/RPC bottlenecks first. diff --git a/.dockerignore b/.dockerignore index c9a95aa1..971139da 100644 --- a/.dockerignore +++ b/.dockerignore @@ -16,6 +16,7 @@ !src/plugins/** !src/driver/** !src/wizard/** +!src/trafficsim/** !plugins/internal/** !plugins/transfuser_driver/** !e2e_challenge/ diff --git a/.gitattributes b/.gitattributes index 4b03373b..98701791 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,3 +6,4 @@ src/ddb/tests/test-case.rclog filter=lfs diff=lfs merge=lfs -text **/*.usdz filter=lfs diff=lfs merge=lfs -text src/runtime/tests/data/integration/0.asl filter=lfs diff=lfs merge=lfs -text src/tools/tests/data/last.usdz filter=lfs diff=lfs merge=lfs -text +data/trafficsim-models/** filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore index 3d01e0fa..2a0c547d 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ __pycache__/ # AI stuff .claude +.agents/skills/iad-slurm .serena .zed CLAUDE.md diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cb28c957..7303ec86 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -51,4 +51,4 @@ repos: files: ^src/wizard/.*\.py$ args: - --config-file=mypy.ini - additional_dependencies: ["types-PyYAML", "hydra-core", "GitPython", "pandas", "pandas-stubs", "polars>=1.0.0", "types-aiofiles"] + additional_dependencies: ["types-PyYAML", "hydra-core", "GitPython", "numpy<2.3", "pandas", "pandas-stubs", "polars>=1.0.0", "types-aiofiles"] diff --git a/AGENTS.md b/AGENTS.md index 4c835bf7..b1d741d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,11 +13,34 @@ Alpasim is a lightweight, data-driven research simulator for autonomous vehicle ## Build and run (quick reference) - **Environment**: `source setup_local_env.sh` (or `./setup_local_env.sh`). The project uses **uv** for dependencies and scripts; use `uv run` for commands (e.g. `uv run pytest`, `uv run alpasim_wizard ...`). +- **Internal configs**: before using `plugins/internal` configs such as `deploy=iad`, run `uv sync --all-packages --extra internal` from the repo root. Prefer repo-root commands like `uv run alpasim_wizard ...`; if using `uv run --project src/wizard`, make sure that project env can see the `alpasim-internal` entry point, otherwise Hydra will not find internal config groups. - **After changing `.proto` files**: `cd src/grpc && uv run compile-protos` - **Run simulation locally** (from repo root or `src/wizard`): `uv run alpasim_wizard deploy=local topology=1gpu driver=vavam wizard.log_dir=./my_run` (deploy configs live in `src/wizard/configs/deploy/`.) - **Tests**: `uv run pytest` (e.g. `uv run pytest src/runtime/tests`) - **Static checks**: `pre-commit run --all-files` +## Coding principles + +- Prefer readability over flexibility. Add an abstraction only when it reduces + net complexity or removes meaningful duplication. +- Keep one canonical path for each behavior. Avoid compatibility shims, silent + fallbacks, aliases, and parallel old/new code unless there is a current + requirement. +- Keep behavior local. Avoid one-line wrappers, gratuitous delegation, and class + hierarchies unless the extra hop makes the code easier to understand or + enables real reuse. +- Fail fast on unexpected input. Prefer direct required access over defensive + `getattr` / `.get(..., default)` patterns that hide broken invariants. +- Reuse existing project helpers and upstream libraries before adding local + equivalents. +- Keep dependency direction clear: generic/shared modules must not import from + specific deployment, policy, or test modules. +- Comments and docstrings must describe the current code. Do not add + change-history comments, restatements of obvious code, or speculative notes + about future support. +- Tests should prove behavior and regressions, not mirror implementation details + or lock down defaults already obvious at the call site. + ## Environment and external repos Environment variables and external repository URLs are project- or environment-specific; see CONTRIBUTING and your setup for local development. @@ -25,6 +48,7 @@ Environment variables and external repository URLs are project- or environment-s ## Commit and pull request guidelines - Keep commits focused and imperative. Rebase onto `main` before submitting; force-pushes are expected after rebases. +- Changelogs are append-only: add new entries, but never change previous entries. - Pipelines auto-bump versions for touched packages; allow the bot-generated commit to land and re-trigger CI if needed. - PRs should explain scenario impact, reference issue IDs, and attach logs/screens for wizard/runtime regressions. Confirm tests and `pre-commit` pass before requesting review. - When pushing to a branch that has the auto-bump commit "Alpasim automatic version bump", force push over it if that's the only commit you'd overwrite. Do not manually update docker container versions; that is done by the CI pipeline. diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b14dd03..5569c759 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,29 @@ This document lists major updates which change UX and require adaptation. It should be sorted by date (more recent on top) and link to MRs which introduce the changes. +## Runtime telemetry with Prometheus and Grafana (30.06.26) +AlpaSim runs now start Prometheus telemetry by default. The wizard allocates +metrics ports, starts Prometheus support services, writes scrape configuration, +and preserves the Prometheus TSDB under the run directory. Runs still generate +`metrics_plot.png`. + +Grafana dashboard resources and a helper script are available for inspecting +local or shared Prometheus file-SD targets: + +```bash +src/tools/scripts/start-prometheus-grafana.sh +``` + +Eval aggregation no longer reads Prometheus runtime metric summaries or adds +runtime performance fields to driving metric outputs. Runs no longer generate +`prometheus/runtime_metrics_summary.json`; query Prometheus or use Grafana for +runtime performance analysis. + +## SMART/CATK trafficsim integration (29.06.26) +Added an integrated CATK-backed traffic simulation service. Use +`trafficsim=catk` to run the SMART/CATK traffic predictor from the AlpaSim base +image with model weights stored under `data/trafficsim-models`. + ## Upgrade OSS renderer to NRE-GA 26.04 (11.06.26) Bumped the `base_config.yaml` renderer image to `nvcr.io/nvidia/nre/nre-ga:26.04` and repointed `defines.renderer_entrypoint` to the `/app/run` symlink (the old path was renamed in 26.04). diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 00000000..0364001a --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,46 @@ +# AlpaSim + +AlpaSim runs autonomous vehicle simulation experiments and records both driving +quality metrics and runtime telemetry for later analysis. + +## Language + +**Run UUID**: +The unique identifier for one AlpaSim startup/run directory. Use this to query +or inspect one concrete run instance. +_Avoid_: run id, run identifier + +**Run Name**: +A human-readable label for an AlpaSim run. It is useful for display, but it is +not guaranteed to be unique. +_Avoid_: run id + +**NRE Run ID**: +The upstream neural rendering/data-source run identifier attached to scene +metadata. It is distinct from an AlpaSim Run UUID. +_Avoid_: run id + +**Runtime Parent**: +The top-level AlpaSim runtime owner for one startup. It owns run-level topology +and coordinates runtime workers. +_Avoid_: parent process, daemon, DaemonEngine + +**Simulation Service**: +A runtime-facing service that participates in an AlpaSim simulation topology. +Simulation services are the services a Runtime Parent can connect to during a run. +_Avoid_: normal service + +**Support Service**: +A wizard-managed service that supports an AlpaSim run without participating in +the simulation topology. +_Avoid_: normal service, telemetry service + +**Prometheus Support Service**: +The Support Service that runs Prometheus-related observability processes for an +AlpaSim run. +_Avoid_: telemetry sidecar + +**Slurm Process Exporter**: +The process metrics exporter used by the Prometheus Support Service on Slurm to +report CPU and memory usage for processes in one Slurm job. +_Avoid_: process telemetry sidecar diff --git a/Dockerfile b/Dockerfile index b082361d..262f3738 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,17 +10,31 @@ FROM nvidia/cuda:12.4.1-cudnn-devel-ubuntu22.04 AS base-amd64 FROM nvcr.io/nvidia/pytorch:25.08-py3 AS base-arm64 +FROM nvcr.io/nvidia/k8s/dcgm-exporter:4.4.1-4.6.0-ubuntu22.04@sha256:b7a4241c608253aa829041cc3575ea57082491251a4a626bcdddc68eaf9a3101 AS dcgm-exporter ARG TARGETARCH FROM base-${TARGETARCH} COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ +COPY --from=dcgm-exporter /usr/bin/dcgm-exporter /usr/bin/dcgm-exporter +COPY --from=dcgm-exporter /etc/dcgm-exporter /etc/dcgm-exporter +RUN printf '%s\n' \ + 'DCGM_FI_DEV_FB_TOTAL, gauge, Total framebuffer memory (in MiB).' \ + >> /etc/dcgm-exporter/default-counters.csv +ARG DEBIAN_FRONTEND=noninteractive RUN apt-get update && apt-get install -y \ git \ ffmpeg \ curl \ build-essential \ libgl1 \ + prometheus \ + prometheus-node-exporter \ + prometheus-process-exporter \ + datacenter-gpu-manager-4-cuda12 \ + && dcgm_lib="$(find /usr/lib /lib -name libdcgm.so.4 -print -quit)" \ + && ln -sf "$dcgm_lib" "$(dirname "$dcgm_lib")/libdcgm.so" \ + && ldconfig \ && rm -rf /var/lib/apt/lists/* # Install Rust toolchain (required for utils_rs maturin build) @@ -45,5 +59,19 @@ RUN --mount=type=secret,id=netrc,target=/root/.netrc \ --mount=type=cache,target=/root/.cache/uv \ sh -c 'if [ -f /root/.netrc ]; then export NETRC=/root/.netrc; fi && uv sync --extra all' +ARG PYTORCH_VERSION=2.8.0+cu128 +ARG TORCH_CLUSTER_VERSION=1.6.3 +ARG TORCH_SCATTER_VERSION=2.1.2 +ARG TORCH_SPARSE_VERSION=0.6.18 + +# Install PyG compiled extensions (torch-cluster, torch-scatter, torch-sparse) +# from pre-built wheels matching the installed torch + CUDA versions. +RUN PYG_WHEEL_URL="https://data.pyg.org/whl/torch-${PYTORCH_VERSION}.html" && \ + uv pip install \ + "torch-cluster==${TORCH_CLUSTER_VERSION}" \ + "torch-scatter==${TORCH_SCATTER_VERSION}" \ + "torch-sparse==${TORCH_SPARSE_VERSION}" \ + -f "$PYG_WHEEL_URL" + ENV UV_CACHE_DIR=/tmp/uv-cache ENV UV_NO_SYNC=1 diff --git a/README.md b/README.md index 42797978..1cf99ae1 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ For cluster or SLURM deployment, see `src/tools/run-on-slurm`. - **[Video Model Renderer](docs/VIDEO_MODEL.md)**: Running OmniDreams through FlashDreams as the AlpaSim renderer backend - **[Operations Guide](docs/OPERATIONS.md)**: Performance tuning, configuration, and troubleshooting +- **[Telemetry Guide](docs/TELEMETRY.md)**: Prometheus metrics, persistence, and central discovery - **[Data Pipeline](docs/DATA_PIPELINE.md)**: ASL log format and reading logs - **[Design Documentation](docs/DESIGN.md)**: Technical architecture and design decisions - **[Plugin System](docs/PLUGIN_SYSTEM.md)**: Extending AlpaSim with custom models, configs, and tools diff --git a/data/trafficsim-models/catk_v120/config.yaml b/data/trafficsim-models/catk_v120/config.yaml new file mode 100644 index 00000000..ae7a3422 --- /dev/null +++ b/data/trafficsim-models/catk_v120/config.yaml @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:60f40743c5df7b170a2a58a4f2f4f122301508e513ac495a993fe34180511344 +size 8049 diff --git a/data/trafficsim-models/catk_v120/latest.ckpt b/data/trafficsim-models/catk_v120/latest.ckpt new file mode 100644 index 00000000..9599f2d3 --- /dev/null +++ b/data/trafficsim-models/catk_v120/latest.ckpt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7c5a89bc6e876c025a82572b72f87ca97dd75fe5f57245dbf2b63fc3b3c4455e +size 69960427 diff --git a/data/trafficsim-models/tokens/cabs_tc_base_agent_vocab_c1024.pkl b/data/trafficsim-models/tokens/cabs_tc_base_agent_vocab_c1024.pkl new file mode 100644 index 00000000..79fdcf01 --- /dev/null +++ b/data/trafficsim-models/tokens/cabs_tc_base_agent_vocab_c1024.pkl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3d6ac36663262cc3b475a87ce2aa2fd48427584a0fb7f6ce593236fef6ba9143 +size 590129 diff --git a/data/trafficsim-models/tokens/cabs_tc_map_vocab_c2048_t1000.pkl b/data/trafficsim-models/tokens/cabs_tc_map_vocab_c2048_t1000.pkl new file mode 100644 index 00000000..d416396c --- /dev/null +++ b/data/trafficsim-models/tokens/cabs_tc_map_vocab_c2048_t1000.pkl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:624775cfd1fc6dcce735cf11d2810012fde965d760807db0ca7c6794d8ee60f9 +size 49322 diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 21f1c305..1d805da0 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -276,9 +276,10 @@ After a run completes, results are in `wizard.log_dir` (e.g., `runs/{RUN_DIR}/`) - `metrics_results.png` - Visual summary of driving quality metrics - `metrics_unprocessed.parquet` - Combined metrics from all rollouts - `videos/` - Organized by violation types -- **`metrics/`** - Performance profiling data: - - `metrics.prom` - Prometheus metrics from simulation - - `metrics_plot.png` - Performance visualization (CPU/GPU/RPC metrics) +- **`prometheus/`** - Performance profiling data: + - `data/` - local Prometheus TSDB for the run + - `targets/alpasim.json` - generated Prometheus file-SD targets +- **`metrics_plot.png`** - Performance visualization (CPU/GPU/RPC metrics) - **`txt-logs/`** - Service logs for debugging - **`wizard-config.yaml`** - Resolved configuration used for this run @@ -334,7 +335,7 @@ problematic scenarios. After each simulation run, Alpasim automatically generates a comprehensive performance visualization: -**Location**: `runs/{RUN_DIR}/metrics/metrics_plot.png` +**Location**: `runs/{RUN_DIR}/metrics_plot.png` This 3×3 grid plot includes: @@ -349,7 +350,7 @@ This 3×3 grid plot includes: - Rollout Duration histogram - Total time per rollout - Step Duration histogram - Time per simulation step -- Service Configuration table - Shows replica counts and capacity +- Run summary - Shows runtime idle fraction and seconds per rollout **Row 3: Resource Utilization** @@ -423,8 +424,6 @@ request addresses override the configured driver pool for that RPC only. To stop the server, call `RuntimeService.shut_down()`. In managed deployments, Docker Compose or Slurm tears down backing services after the runtime exits. -The legacy `runtime.endpoints.do_shutdown` setting is kept for compatibility -but no longer controls backing-service RPC shutdown. When running a generated `docker-compose.yaml` manually for one-shot simulations, start Compose with `--exit-code-from runtime-0`, for example: diff --git a/docs/TELEMETRY.md b/docs/TELEMETRY.md new file mode 100644 index 00000000..8599b87f --- /dev/null +++ b/docs/TELEMETRY.md @@ -0,0 +1,53 @@ +# Performance telemetry + +Every AlpaSim run starts Prometheus telemetry by default. No configuration is needed for the basic +setup. The wizard allocates ports, starts one Prometheus support service, starts runtime worker +`/metrics` endpoints, and writes the scrape configuration into the run directory. + +At the end of the simulation, the runtime queries the Prometheus server and generates +`metrics_plot.png`. + +The Prometheus data persists under `prometheus/data` and can be read by restarting a Prometheus +server later. + +## Central Prometheus discovery + +By default, AlpaSim publishes Prometheus file-SD targets under +`${defines.filesystem}/prometheus/file-sd`. For `deploy=local`, this resolves under the repo data +directory. For `deploy=iad`, it resolves under the shared IAD Lustre filesystem. + +The wizard publishes one central discovery file: + +```text +/shared/prometheus/alpasim/.json +``` + +A central Prometheus can discover active AlpaSim runs with: + +```yaml +scrape_configs: + - job_name: alpasim + file_sd_configs: + - files: + - /shared/prometheus/alpasim/*.json + refresh_interval: 10s +``` + +For normal Docker Compose and Slurm runs, the wizard removes this file when the deployment exits. +If a run crashes before cleanup, later AlpaSim startups conservatively remove old discovery files +only when they are at least five hours old and all listed targets are unreachable. + +To start a local Prometheus and Grafana against a local or mounted file-SD directory, run: + +```bash +src/tools/scripts/start-prometheus-grafana.sh /shared/prometheus/alpasim +``` + +The file-SD directory can also be an SSH path. The script mounts it locally with `sshfs`: + +```bash +src/tools/scripts/start-prometheus-grafana.sh \ + :/lustre/fsw/portfolios/av/projects/av_alpamayo_reasoning/data/av_alpamayo_sim/.cache/prometheus/file-sd +``` + +The script prints the Prometheus and Grafana URLs. diff --git a/docs/TUTORIAL.md b/docs/TUTORIAL.md index d85273dc..383bc40f 100644 --- a/docs/TUTORIAL.md +++ b/docs/TUTORIAL.md @@ -50,6 +50,8 @@ Let's start by executing a run with default settings. 1. Run the wizard to create the necessary config files, download the scene (if necessary), and run a simulation: `uv run alpasim_wizard deploy=local topology=1gpu driver=vavam wizard.log_dir=$PWD/tutorial`. This will create a `tutorial/` directory with all necessary config files and run the simulation. +1. Alternatively, to run with the catk traffic model enabled, run: + `uv run alpasim_wizard deploy=local topology=1gpu driver=vavam trafficsim=catk wizard.log_dir=$PWD/tutorial_catk`. ## Results structure @@ -122,10 +124,18 @@ tutorial/ ├── eval-config.yaml ├── generated-network-config.yaml ├── generated-user-config-0.yaml -├── metrics +├── metrics_plot.png +├── prometheus +│   ├── data +│   ├── process-exporter.yml +│   ├── prometheus.yml +│   ├── rules +│   │   └── alpasim-recording-rules.yml +│   └── targets +│   └── alpasim.json ├── run_metadata.yaml ├── run.sh -├── trafficsim-config.yaml +├── trafficsim-config.yaml # optional ├── txt-logs ├── wizard-config-loadable.yaml └── wizard-config.yaml @@ -153,18 +163,24 @@ Some noteworthy files and directories: - `metrics_results.png` - Visual summary of driving quality metrics - `metrics_unprocessed.parquet` - Combined metrics from all rollouts - `videos/` - Videos organized by violation type (collision_at_fault, offroad, etc.) -* `metrics/` contains performance profiling data (see - [OPERATIONS.md](OPERATIONS.md#how-do-i-view-performance-metrics) for details): - - `metrics.prom` - Prometheus metrics from simulation - - `metrics_plot.png` - Performance visualization (CPU/GPU/RPC metrics) +* `prometheus/` contains performance telemetry data and Prometheus config: + - `prometheus/data/` - local Prometheus TSDB for the run + - `prometheus/prometheus.yml` - generated local Prometheus scrape config + - `prometheus/targets/alpasim.json` - generated file-SD targets + - `prometheus/rules/alpasim-recording-rules.yml` - generated recording rules for common + runtime dashboard queries + - `prometheus/process-exporter.yml` - generated process grouping config +* `metrics_plot.png` is the automatically generated performance visualization (CPU/GPU/RPC + metrics). See [Performance telemetry](TELEMETRY.md) to inspect live or persisted metrics with + Prometheus and Grafana. * `driver` is a directory with logs written by the driver service, useful to debug policy-internal problems. * `wizard-config.yaml` contains the config the wizard used for this run **after applying the inheritance of hydra**. This is useful for debugging configuration issues. * `generated-user-config-{ARRAY_ID}.yaml` contains an expanded version of the simulation config provided by the user, possibly split into chunks when simulating on multiple nodes. -* `trafficsim-config.yaml`. A copy of the traffic simulation config used for simulation, useful for - debugging traffic simulation. +* `trafficsim-config.yaml` is present only for traffic backends that consume a wizard-generated + backend config. It is useful for debugging backend settings. * `generated-network-config.yaml` describes which services listen on which ports during simulation. Not useful unless debugging the simulator itself. @@ -182,7 +198,6 @@ something goes wrong. When an error happens (for example the `rollouts` director it's best to consult that log to see where the first errors occurred. The microservices may produce additional logs that can be useful for debugging, but that is not covered here. - ### Configuration axes The wizard requires three config groups: diff --git a/pyproject.toml b/pyproject.toml index 71b43468..fb3fe0ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "alpasim_workspace" -version = "0.91.0" +version = "0.96.0" description = "Workspace for Alpamayo Sim development" readme = "README.md" requires-python = ">=3.11,<3.13" @@ -19,6 +19,7 @@ physics = ["alpasim-physics"] tools = ["alpasim-tools"] driver = ["alpasim_driver"] wizard = ["alpasim_wizard"] +trafficsim = ["alpasim-trafficsim"] # -- Composite extras -- # All core (non-plugin) packages @@ -33,6 +34,7 @@ all = [ "alpasim-tools", "alpasim_driver", "alpasim_wizard", + "alpasim-trafficsim", ] # -- Plugin extras -- @@ -51,6 +53,7 @@ members = [ "src/physics", "src/tools", "src/driver", + "src/trafficsim", "plugins/*", ] @@ -67,6 +70,7 @@ alpasim_driver = { workspace = true } alpasim_transfuser = { workspace = true } alpasim_wizard = { workspace = true } trajdata-alpasim = { git = "https://github.com/NVlabs/trajdata", rev = "3caf3a8bd1a68f4a1545352aea82c535be9510b2" } +alpasim-trafficsim = { workspace = true } [tool.uv] python-preference = "only-managed" diff --git a/setup_local_env.sh b/setup_local_env.sh index cb6b0016..bf31677c 100755 --- a/setup_local_env.sh +++ b/setup_local_env.sh @@ -93,6 +93,10 @@ for plugin_dir in "${!PLUGIN_EXTRAS[@]}"; do extra_name="${PLUGIN_EXTRAS[$plugin_dir]}" echo " Found plugin: ${plugin_dir} (extra: ${extra_name})" EXTRAS+=("--extra" "${extra_name}") + + if [[ -x "${REPO_ROOT}/${plugin_dir}/data/install-agent-skills.sh" ]]; then + "${REPO_ROOT}/${plugin_dir}/data/install-agent-skills.sh" + fi fi done diff --git a/src/controller/pyproject.toml b/src/controller/pyproject.toml index 144da7e8..c62cf63d 100644 --- a/src/controller/pyproject.toml +++ b/src/controller/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "alpasim_controller" -version = "0.55.0" +version = "0.59.0" description = "A simple, open source controller + vehicle model" requires-python = ">=3.11,<3.13" dependencies = [ diff --git a/src/driver/pyproject.toml b/src/driver/pyproject.toml index b1642978..1ba8d013 100644 --- a/src/driver/pyproject.toml +++ b/src/driver/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "uv_build" [project] name = "alpasim_driver" -version = "0.53.0" +version = "0.57.0" description = "Adapters for open-source drivers in Alpasim" readme = "README.md" requires-python = ">=3.12,<3.13" diff --git a/src/driver/src/alpasim_driver/main.py b/src/driver/src/alpasim_driver/main.py index 3d214295..bc950c80 100644 --- a/src/driver/src/alpasim_driver/main.py +++ b/src/driver/src/alpasim_driver/main.py @@ -51,6 +51,7 @@ add_EgodriverServiceServicer_to_server, ) from alpasim_plugins.plugins import models as model_registry +from alpasim_utils.geometry import quat_to_yaw, yaw_to_quat_components from omegaconf import OmegaConf from PIL import Image @@ -100,22 +101,6 @@ def _get_external_ip() -> str: return "unknown" -def _quat_to_yaw(quaternion: Quat) -> float: - """Extract the yaw component (rotation about +Z) from a quaternion.""" - - return np.arctan2( - 2.0 * (quaternion.w * quaternion.z + quaternion.x * quaternion.y), - 1.0 - 2.0 * (quaternion.y * quaternion.y + quaternion.z * quaternion.z), - ) - - -def _yaw_to_quat(yaw: float) -> Quat: - """Create a Z-only rotation quaternion from the provided yaw angle.""" - - half_yaw = 0.5 * yaw - return Quat(w=float(np.cos(half_yaw)), x=0.0, y=0.0, z=float(np.sin(half_yaw))) - - def _rig_est_offsets_to_local_positions( current_pose_in_local: PoseAtTime, offsets_in_rig: np.ndarray ) -> np.ndarray: @@ -125,7 +110,7 @@ def _rig_est_offsets_to_local_positions( curr_y = current_pose_in_local.pose.vec.y curr_quat = current_pose_in_local.pose.quat - curr_yaw = _quat_to_yaw(curr_quat) + curr_yaw = quat_to_yaw(curr_quat) cos_yaw = np.cos(curr_yaw) sin_yaw = np.sin(curr_yaw) @@ -1008,19 +993,20 @@ def _convert_prediction_to_alpasim_trajectory( timestamps_us = (time_now_us + steps * time_delta_us).tolist() # Transform model headings from rig frame to local frame - current_yaw = _quat_to_yaw(current_pose.pose.quat) + current_yaw = quat_to_yaw(current_pose.pose.quat) local_yaws = prediction.headings + current_yaw for local_xy, yaw, timestamp_us in zip( local_positions, local_yaws, timestamps_us, strict=True ): local_x, local_y = map(float, local_xy) + quat_w, quat_x, quat_y, quat_z = yaw_to_quat_components(float(yaw)) trajectory.poses.append( PoseAtTime( pose=Pose( vec=Vec3(x=local_x, y=local_y, z=curr_z), - quat=_yaw_to_quat(float(yaw)), + quat=Quat(w=quat_w, x=quat_x, y=quat_y, z=quat_z), ), timestamp_us=timestamp_us, ) diff --git a/src/eval/src/eval/accumulator.py b/src/eval/src/eval/accumulator.py index 19893fad..d46130eb 100644 --- a/src/eval/src/eval/accumulator.py +++ b/src/eval/src/eval/accumulator.py @@ -21,6 +21,7 @@ from alpasim_grpc.v0.common_pb2 import AABB from alpasim_grpc.v0.egodriver_pb2 import DriveResponse from alpasim_grpc.v0.logging_pb2 import ActorPoses, LogEntry, RolloutMetadata +from alpasim_grpc.v0.traffic_pb2 import TrafficReturn, TrafficSessionRequest from alpasim_utils.geometry import ( Pose, Trajectory, @@ -36,6 +37,7 @@ RenderableTrajectory, Routes, ScenarioEvalInput, + TrafficPredictions, ) from eval.schema import EvalConfig @@ -88,6 +90,13 @@ class EvalDataAccumulator: default_factory=list, init=False ) + # Traffic request/response pairing and metadata + _pending_traffic_query_us: int | None = field(default=None, init=False) + _static_actor_ids: set[str] = field(default_factory=set, init=False) + _traffic_predictions: TrafficPredictions = field( + default_factory=TrafficPredictions, init=False + ) + # Camera and route data _cameras: Cameras = field(default_factory=Cameras, init=False) _routes: Routes = field(default_factory=Routes, init=False) @@ -133,6 +142,12 @@ def handle_message(self, message: LogEntry) -> None: (*self._pending_request, message.driver_return) ) self._pending_request = None + elif msg_type == "traffic_session_request": + self._handle_traffic_session_request(message.traffic_session_request) + elif msg_type == "traffic_request": + self._pending_traffic_query_us = int(message.traffic_request.time_query_us) + elif msg_type == "traffic_return": + self._handle_traffic_return(message.traffic_return) elif msg_type == "available_cameras_return": for available_camera in message.available_cameras_return.available_cameras: self._cameras.add_calibration(available_camera) @@ -179,6 +194,31 @@ def _handle_rollout_metadata(self, metadata: RolloutMetadata) -> None: metadata.ego_rig_recorded_ground_truth_trajectory ).transform(self._ego_coords_rig_to_aabb_center, is_relative=True) + def _handle_traffic_session_request(self, request: TrafficSessionRequest) -> None: + self._static_actor_ids = { + str(obj.object_id) + for obj in request.logged_object_trajectories + if obj.is_static + } + + def _handle_traffic_return(self, traffic_return: TrafficReturn) -> None: + if self._pending_traffic_query_us is None: + return + + query_time_us = self._pending_traffic_query_us + self._pending_traffic_query_us = None + object_trajectories: dict[str, Trajectory] = {} + for update in traffic_return.object_trajectory_updates: + object_id = str(update.object_id) + if object_id == "EGO" or object_id in self._static_actor_ids: + continue + trajectory = trajectory_from_grpc(update.trajectory) + if trajectory.is_empty() or trajectory.time_range_us.stop <= query_time_us: + continue + object_trajectories[object_id] = trajectory + + self._traffic_predictions.add_prediction(query_time_us, object_trajectories) + def _handle_actor_poses(self, poses_message: ActorPoses) -> None: """Accumulate actor poses for trajectory building. @@ -365,6 +405,7 @@ def build_scenario_eval_input( actor_trajectories=actor_trajectories, ego_recorded_ground_truth_trajectory=self._gt_ego_trajectory, driver_responses=driver_responses, + traffic_predictions=self._traffic_predictions, vec_map=vec_map, cameras=self._cameras if self._cameras.camera_by_logical_id else None, routes=self._routes if self._routes.routes_in_rig_frame else None, diff --git a/src/eval/src/eval/aggregation/main.py b/src/eval/src/eval/aggregation/main.py index 55bc2a13..83a30410 100644 --- a/src/eval/src/eval/aggregation/main.py +++ b/src/eval/src/eval/aggregation/main.py @@ -13,7 +13,7 @@ import polars as pl from omegaconf import OmegaConf -from eval.aggregation import processing, telemetry, utils +from eval.aggregation import processing, utils from eval.aggregation.failed_rollouts import FailedRolloutInput from eval.aggregation.modifiers import ( MetricAggregationModifiers, @@ -100,16 +100,12 @@ def _aggregate_metrics( len(job_dirs), ) df = pl.concat(all_dfs) - telemetry_summary = telemetry.collect_driver_drive_rpc_latency(job_dirs) - return processing.aggregate_and_write_metrics_results_txt( df, force_same_run=True, output_path=str(aggregate_dir), additional_modifiers=modifiers, failed_rollouts=failed_rollouts, - run_level_metrics=telemetry.run_level_metrics_from_summary(telemetry_summary), - telemetry_summary=telemetry_summary, scene_score_config=scene_score_config, ) diff --git a/src/eval/src/eval/aggregation/processing.py b/src/eval/src/eval/aggregation/processing.py index 8250b40b..26dcbee6 100644 --- a/src/eval/src/eval/aggregation/processing.py +++ b/src/eval/src/eval/aggregation/processing.py @@ -273,7 +273,6 @@ def write_results_summary_json( df_wide_avg_t_clip_rollout: pl.DataFrame, output_path: str, failed_rollouts: list[FailedRolloutInput] | None = None, - telemetry_summary: dict[str, object] | None = None, scene_score_config: SceneScoreConfig | None = None, ) -> None: """Write per-rollout scoring results with run-level aggregate metrics.""" @@ -360,9 +359,6 @@ def write_results_summary_json( "collision_at_fault": "== 0", "offroad": "== 0", } - if telemetry_summary: - payload["telemetry"] = telemetry_summary - output_file = os.path.join(output_path, "results-summary.json") with open(output_file, "w") as f: json.dump(_json_safe(payload), f, indent=2, sort_keys=True, allow_nan=False) @@ -690,8 +686,6 @@ def aggregate_and_write_metrics_results_txt( output_path: str | None = None, additional_modifiers: list[MetricAggregationModifiers] | None = None, failed_rollouts: list[FailedRolloutInput] | None = None, - run_level_metrics: dict[str, object] | None = None, - telemetry_summary: dict[str, object] | None = None, scene_score_config: SceneScoreConfig | None = None, ) -> ProcessedMetricDFs: """ @@ -790,22 +784,6 @@ def aggregate_and_write_metrics_results_txt( how="left", ) - if run_level_metrics: - scalar_metrics = { - key: value - for key, value in run_level_metrics.items() - if value is None or isinstance(value, (bool, int, float, str)) - } - if scalar_metrics: - df_wide_avg_t_clip_rollout = df_wide_avg_t_clip_rollout.with_columns( - [pl.lit(value).alias(key) for key, value in scalar_metrics.items()] - + [ - pl.lit(None).alias(f"{key}_std") - for key in scalar_metrics - if not key.endswith("_std") - ] - ) - df_wide_avg_t = df_wide_avg_t.join( trajectory_uid_df.select( pl.col("trajectory_uid"), @@ -835,7 +813,6 @@ def aggregate_and_write_metrics_results_txt( df_wide_avg_t_clip_rollout, output_path, failed_rollouts=failed_rollouts, - telemetry_summary=telemetry_summary, scene_score_config=scene_score_config, ) plot_metrics_results(df_wide_avg_t_clip, trajectory_uid_df, output_path) diff --git a/src/eval/src/eval/aggregation/telemetry.py b/src/eval/src/eval/aggregation/telemetry.py deleted file mode 100644 index f4a6b680..00000000 --- a/src/eval/src/eval/aggregation/telemetry.py +++ /dev/null @@ -1,151 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# Copyright (c) 2026 NVIDIA Corporation - -"""Helpers for extracting aggregate telemetry from runtime Prometheus files.""" - -from __future__ import annotations - -import logging -import pathlib -import re - -logger = logging.getLogger(__name__) - -_PROM_SAMPLE_RE = re.compile( - r"^(?P[a-zA-Z_:][a-zA-Z0-9_:]*)(?:\{(?P.*)\})?\s+" - r"(?P[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?)$" -) -_PROM_LABEL_RE = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"') - - -def _unescape_prometheus_label_value(value: str) -> str: - result = [] - idx = 0 - while idx < len(value): - char = value[idx] - if char != "\\" or idx + 1 >= len(value): - result.append(char) - idx += 1 - continue - - escaped = value[idx + 1] - if escaped == "n": - result.append("\n") - elif escaped in {'"', "\\"}: - result.append(escaped) - else: - result.append(f"\\{escaped}") - idx += 2 - return "".join(result) - - -def _parse_labels(raw_labels: str | None) -> dict[str, str]: - if not raw_labels: - return {} - return { - match.group(1): _unescape_prometheus_label_value(match.group(2)) - for match in _PROM_LABEL_RE.finditer(raw_labels) - } - - -def _iter_prometheus_samples(path: pathlib.Path): - with path.open("r", encoding="utf-8") as handle: - for raw_line in handle: - line = raw_line.strip() - if not line or line.startswith("#"): - continue - match = _PROM_SAMPLE_RE.match(line) - if not match: - continue - yield ( - match.group("name"), - _parse_labels(match.group("labels")), - float(match.group("value")), - ) - - -def extract_driver_drive_rpc_latency( - metrics_paths: list[pathlib.Path], - *, - tag: str = "default", -) -> dict[str, object] | None: - """Compute aggregate driver ``drive`` RPC latency from Prometheus files. - - The runtime writes ``rpc_duration_seconds_sum`` and - ``rpc_duration_seconds_count`` counters per worker. The mean latency is the - sum of all matching durations divided by the sum of all matching counts. - """ - - total_sum_s = 0.0 - total_count = 0.0 - files_used: list[str] = [] - - for metrics_path in metrics_paths: - if not metrics_path.exists(): - continue - - file_sum_s = 0.0 - file_count = 0.0 - file_has_sum = False - file_has_count = False - try: - for name, labels, value in _iter_prometheus_samples(metrics_path): - if labels.get("service") != "driver": - continue - if labels.get("method") != "drive": - continue - if labels.get("tag", "default") != tag: - continue - if name == "rpc_duration_seconds_sum": - file_has_sum = True - file_sum_s += value - elif name == "rpc_duration_seconds_count": - file_has_count = True - file_count += value - except OSError as exc: - logger.warning( - "Could not read telemetry metrics from %s: %s", metrics_path, exc - ) - continue - - if file_has_sum and file_has_count and file_count > 0: - files_used.append(str(metrics_path)) - total_sum_s += file_sum_s - total_count += file_count - - if total_count <= 0: - return None - - return { - "driver_drive_rpc_duration_mean_s": total_sum_s / total_count, - "driver_drive_rpc_duration_sum_s": total_sum_s, - "driver_drive_rpc_duration_count": int(total_count), - "source": "telemetry/metrics.prom", - "tag": tag, - "files_used": files_used, - } - - -def collect_driver_drive_rpc_latency( - job_dirs: list[pathlib.Path], - *, - tag: str = "default", -) -> dict[str, object] | None: - metrics_paths = [job_dir / "telemetry" / "metrics.prom" for job_dir in job_dirs] - return extract_driver_drive_rpc_latency(metrics_paths, tag=tag) - - -def run_level_metrics_from_summary( - telemetry_summary: dict[str, object] | None, -) -> dict[str, object] | None: - if not telemetry_summary: - return None - return { - key: telemetry_summary[key] - for key in ( - "driver_drive_rpc_duration_mean_s", - "driver_drive_rpc_duration_sum_s", - "driver_drive_rpc_duration_count", - ) - if key in telemetry_summary - } diff --git a/src/eval/src/eval/data.py b/src/eval/src/eval/data.py index 80dc2895..003fff7d 100644 --- a/src/eval/src/eval/data.py +++ b/src/eval/src/eval/data.py @@ -862,6 +862,114 @@ def get_driver_response_for_time( return self.per_timestep_driver_responses[idx] +@dataclasses.dataclass +class TrafficPredictionAtTime: + """Traffic model predictions produced for one query timestamp.""" + + query_time_us: int + object_trajectories: dict[str, geometry.Trajectory] + + +@dataclasses.dataclass +class TrafficPredictions: + """Traffic forecast trajectories keyed by their query timestamps.""" + + timestamps_us: list[int] = dataclasses.field(default_factory=list) + per_timestep_predictions: list[TrafficPredictionAtTime] = dataclasses.field( + default_factory=list + ) + artists: dict[str, list[plt.Artist]] | None = None + + def add_prediction( + self, + query_time_us: int, + object_trajectories: dict[str, geometry.Trajectory], + ) -> None: + if not object_trajectories: + return + self.timestamps_us.append(int(query_time_us)) + self.per_timestep_predictions.append( + TrafficPredictionAtTime( + query_time_us=int(query_time_us), + object_trajectories=object_trajectories, + ) + ) + + def get_prediction_for_time( + self, + time: int, + *, + fallback: Literal["exact", "previous"] = "previous", + ) -> TrafficPredictionAtTime | None: + if not self.timestamps_us: + return None + if fallback == "exact": + idx = np.searchsorted(self.timestamps_us, time) + if idx == len(self.timestamps_us) or self.timestamps_us[idx] != time: + return None + return self.per_timestep_predictions[idx] + if fallback != "previous": + raise ValueError(f"Unsupported traffic-prediction fallback: {fallback}") + idx = np.searchsorted(self.timestamps_us, time, side="right") - 1 + if idx < 0: + return None + return self.per_timestep_predictions[idx] + + @staticmethod + def _future_xy_points( + trajectory: geometry.Trajectory, + time: int, + ) -> np.ndarray: + if trajectory.is_empty() or trajectory.time_range_us.stop <= time: + return np.zeros((0, 2), dtype=np.float32) + + future_positions: list[np.ndarray] = [] + if time in trajectory.time_range_us: + future_positions.append(trajectory.interpolate_pose(int(time)).vec3[:2]) + for idx, ts_us in enumerate(trajectory.timestamps_us): + if int(ts_us) > time: + future_positions.append(trajectory.positions[idx, :2]) + if len(future_positions) < 2: + return np.zeros((0, 2), dtype=np.float32) + return np.asarray(future_positions, dtype=np.float32) + + def remove_artists(self) -> None: + if self.artists is None: + return + for artist_list in self.artists.values(): + for artist in artist_list: + artist.remove() + self.artists = None + + def render_at_time( + self, + ax: plt.Axes, + time: int, + ) -> dict[str, list[plt.Artist]]: + self.remove_artists() + prediction = self.get_prediction_for_time(time, fallback="previous") + if prediction is None: + self.artists = {} + return self.artists + + artists: dict[str, list[plt.Artist]] = {} + for object_id, trajectory in prediction.object_trajectories.items(): + future_xy = self._future_xy_points(trajectory, time) + if future_xy.shape[0] < 2: + continue + artists[object_id] = ax.plot( + future_xy[:, 0], + future_xy[:, 1], + "-", + color="magenta", + linewidth=1.2, + alpha=0.75, + zorder=9, + ) + self.artists = artists + return artists + + @dataclasses.dataclass class ActorPolygonsAtTime: """Captures actor polygons at a given time. Crucially also has an STRtree. @@ -960,6 +1068,7 @@ def render( center: shapely.Point | None = None, max_dist: float | None = None, only_agents: list[str] | None = None, + show_labels: bool = False, ) -> dict[str, list[plt.Artist]]: """Render the actor polygons. @@ -1008,6 +1117,29 @@ def render( polygon.exterior.xy[0], polygon.exterior.xy[1] ) old_agent_artists[agent_id][1].set_xy(polygon.exterior.coords) + if show_labels and agent_id != "EGO": + label_xy = polygon.centroid.coords[0] + if len(old_agent_artists[agent_id]) >= 3: + label_artist = old_agent_artists[agent_id][2] + label_artist.set_position(label_xy) + label_artist.set_text(agent_id) + label_artist.set_visible(True) + else: + old_agent_artists[agent_id].append( + ax.text( + label_xy[0], + label_xy[1], + agent_id, + fontsize=6, + color="black", + ha="center", + va="center", + zorder=20, + ) + ) + elif len(old_agent_artists[agent_id]) >= 3: + old_agent_artists[agent_id][2].remove() + old_agent_artists[agent_id] = old_agent_artists[agent_id][:2] new_agent_artists[agent_id] = old_agent_artists[agent_id] else: new_artists = [] @@ -1027,6 +1159,20 @@ def render( alpha=0.1 if agent_id != "EGO" else 0.3, ) ) + if show_labels and agent_id != "EGO": + label_xy = polygon.centroid.coords[0] + new_artists.append( + ax.text( + label_xy[0], + label_xy[1], + agent_id, + fontsize=6, + color="black", + ha="center", + va="center", + zorder=20, + ) + ) new_agent_artists[agent_id] = new_artists # Remove unused artists @@ -1094,6 +1240,7 @@ def render_at_time( center: shapely.Point | None = None, max_dist: float | None = None, only_agents: list[str] | None = None, + show_labels: bool = False, ) -> dict[str, list[plt.Artist]]: """Render the actor polygons at a given time. @@ -1115,7 +1262,7 @@ def render_at_time( """ polygons_at_time = self.get_polygons_at_time(time) self.artists = polygons_at_time.render( - ax, self.artists, center, max_dist, only_agents + ax, self.artists, center, max_dist, only_agents, show_labels=show_labels ) return self.artists @@ -1772,6 +1919,9 @@ class ScenarioEvalInput: # Driver responses (optional, needed for some metrics) driver_responses: DriverResponses | None = None + # Traffic model forecast trajectories (optional, used for video rendering) + traffic_predictions: TrafficPredictions | None = None + # Vector map (needed for offroad detection) vec_map: VectorMap | None = None @@ -1819,6 +1969,10 @@ class SimulationResult: actor_polygons: ActorPolygons cameras: Cameras routes: Routes + # Traffic model forecast trajectories for non-static actors + traffic_predictions: TrafficPredictions = dataclasses.field( + default_factory=TrafficPredictions + ) # See ScenarioEvalInput.force_gt_duration_us. force_gt_duration_us: int | None = None @@ -1918,6 +2072,10 @@ def from_scenario_input( ), ) + traffic_predictions = scenario_input.traffic_predictions + if traffic_predictions is None: + traffic_predictions = TrafficPredictions() + # Create actor polygons from trajectories actor_polygons = ActorPolygons.from_actor_trajectories(actor_trajectories) @@ -1935,6 +2093,7 @@ def from_scenario_input( actor_trajectories=actor_trajectories, driver_estimated_trajectory=driver_estimated_trajectory, driver_responses=driver_responses, + traffic_predictions=traffic_predictions, ego_recorded_ground_truth_trajectory=ego_recorded_ground_truth_trajectory, vec_map=scenario_input.vec_map, actor_polygons=actor_polygons, diff --git a/src/eval/src/eval/schema.py b/src/eval/src/eval/schema.py index e6f0372a..d7a855e1 100644 --- a/src/eval/src/eval/schema.py +++ b/src/eval/src/eval/schema.py @@ -35,6 +35,11 @@ class MapElements(StrEnum): ROAD_EDGE = "road_edge" STOP_LINE = "stop_line" OTHER_LINE = "other_line" + CROSSWALK = "crosswalk" + ROAD_AREA = "road_area" + ROAD_ISLAND = "road_island" + PED_WALKWAY = "ped_walkway" + TRAFFIC_SIGN = "traffic_sign" # The ground truth trajectory as line on the road. GT_LINESTRING = "gt_linestring" # A "ghost" car driving the ground truth trajectory. @@ -45,6 +50,10 @@ class MapElements(StrEnum): ROUTE = "route" # All other agents in the scene. AGENTS = "agents" + # Traffic model forecast trajectories for non-static agents. + TRAFFIC_PREDICTIONS = "traffic_predictions" + # Text labels for visible non-ego actor IDs. + AGENT_IDS = "agent_ids" class VideoLayout(StrEnum): diff --git a/src/eval/src/eval/video.py b/src/eval/src/eval/video.py index a1d1ced1..f11acd65 100644 --- a/src/eval/src/eval/video.py +++ b/src/eval/src/eval/video.py @@ -542,6 +542,14 @@ def create_video_animation( # Outer key: name of the element to plot # Inner key: name of the element artists to plot (e.g. border and fill) artists_on_map: dict[str, dict[str, list[plt.Artist]]] = {} + show_agent_ids = ( + cfg.video.map_video.map_elements_to_plot is None + or MapElements.AGENT_IDS in cfg.video.map_video.map_elements_to_plot + ) + show_traffic_predictions = ( + cfg.video.map_video.map_elements_to_plot is None + or MapElements.TRAFFIC_PREDICTIONS in cfg.video.map_video.map_elements_to_plot + ) artists_on_map["map"] = shapely_map.render( axs["map"], @@ -572,12 +580,14 @@ def create_video_animation( timestamps_us[0], center=image_center_xy, max_dist=cfg.video.map_video.map_radius_m + 10, + show_labels=show_agent_ids, ) else: artists_on_map["agent_artists"] = sim_result.actor_polygons.render_at_time( axs["map"], timestamps_us[0], only_agents=["EGO"], + show_labels=False, ) if ( @@ -597,6 +607,14 @@ def create_video_animation( timestamps_us[0], ) + if show_traffic_predictions: + artists_on_map["traffic_predictions"] = ( + sim_result.traffic_predictions.render_at_time( + axs["map"], + timestamps_us[0], + ) + ) + if ( cfg.video.map_video.map_elements_to_plot is None or MapElements.EGO_GT_GHOST_POLYGON in cfg.video.map_video.map_elements_to_plot @@ -654,6 +672,14 @@ def update(time: int) -> list[plt.Artist]: time, ) + if show_traffic_predictions: + artists_on_map["traffic_predictions"] = ( + sim_result.traffic_predictions.render_at_time( + axs["map"], + time, + ) + ) + if ( cfg.video.map_video.map_elements_to_plot is None or MapElements.EGO_GT_GHOST_POLYGON @@ -674,12 +700,14 @@ def update(time: int) -> list[plt.Artist]: time, center=image_center_xy, max_dist=cfg.video.map_video.map_radius_m + 10, + show_labels=show_agent_ids, ) else: artists_on_map["agent_artists"] = sim_result.actor_polygons.render_at_time( axs["map"], time, only_agents=["EGO"], + show_labels=False, ) for artist in _list_in_dict_in_dict_to_list(artists_on_map): diff --git a/src/eval/src/eval/video_data.py b/src/eval/src/eval/video_data.py index aedfb445..57b7f083 100644 --- a/src/eval/src/eval/video_data.py +++ b/src/eval/src/eval/video_data.py @@ -16,14 +16,35 @@ @dataclasses.dataclass -class PointPlot: - """Represents a point to be plotted. Used to plot the vec_map.""" +class RenderablePoint: + """Represents a point marker to be plotted from the vec_map.""" name: str - points: list[Point] + point: Point color: str marker: str size: float = 6 + artists: list[plt.Artist] | None = None + + def remove_artist(self) -> None: + if self.artists is not None: + self.artists[0].remove() + self.artists = None + + def render(self, ax: plt.Axes) -> list[plt.Artist]: + if self.artists is not None: + raise RuntimeError("You should not re-render an existing point.") + self.artists = ax.plot( + [self.point.x], + [self.point.y], + linestyle="", + marker=self.marker, + markersize=self.size, + color=self.color, + alpha=0.9, + zorder=4, + ) + return self.artists @dataclasses.dataclass @@ -104,24 +125,34 @@ class ShapelyMap: """Represents a map with shapely objects.""" renderable_linestrings: list[RenderableLineString] - # TODOs: Make work for points - # * Split traffic signs by type - # * Add points to str_tree - # * Rendering error where using rotation transform does something weird. - # point_plots: list[PointPlot] + renderable_points: list[RenderablePoint] str_tree: STRtree currently_rendered_linestring_ids: np.ndarray = dataclasses.field( default_factory=lambda: np.array([]) ) + currently_rendered_point_ids: np.ndarray = dataclasses.field( + default_factory=lambda: np.array([]) + ) @staticmethod def from_vec_map(vec_map: VectorMap | None) -> "ShapelyMap": if vec_map is None: return ShapelyMap( renderable_linestrings=[], + renderable_points=[], str_tree=STRtree([]), ) renderable_linestrings = [] + renderable_points = [] + + def _closed_linestring(xyz: np.ndarray) -> LineString | None: + points = np.asarray(xyz) + if points.ndim != 2 or points.shape[0] < 2 or points.shape[1] < 2: + return None + points_xy = points[:, :2] + if not np.allclose(points_xy[0], points_xy[-1]): + points_xy = np.vstack([points_xy, points_xy[0]]) + return LineString(points_xy) road_lane_elements = vec_map.elements[ maps.vec_map_elements.MapElementType.ROAD_LANE @@ -138,9 +169,18 @@ def from_vec_map(vec_map: VectorMap | None) -> "ShapelyMap": other_line_elements = [ e for e in wait_line_elements.values() if e.wait_line_type != "STOP" ] - # traffic_sign_elements = vec_map.elements[ - # maps.vec_map_elements.MapElementType.TRAFFIC_SIGN - # ] + crosswalk_elements = vec_map.elements.get( + maps.vec_map_elements.MapElementType.PED_CROSSWALK, {} + ) + road_area_elements = vec_map.elements.get( + maps.vec_map_elements.MapElementType.ROAD_AREA, {} + ) + walkway_elements = vec_map.elements.get( + maps.vec_map_elements.MapElementType.PED_WALKWAY, {} + ) + traffic_sign_elements = vec_map.elements.get( + maps.vec_map_elements.MapElementType.TRAFFIC_SIGN, {} + ) for element in road_lane_elements.values(): renderable_linestrings.append( @@ -210,29 +250,95 @@ def from_vec_map(vec_map: VectorMap | None) -> "ShapelyMap": ) ) - # TODO: Currently doesn't work with transform... - # point_plots = [] - # point_plots.append( - # PointPlot( - # name="traffic_sign", - # points=[ - # Point(traffic_sign.position[:2]) - # for traffic_sign in traffic_sign_elements.values() - # ], - # color="orange", - # marker="8", - # ) - # ) + for crosswalk in crosswalk_elements.values(): + linestring = _closed_linestring(crosswalk.polygon.xyz) + if linestring is not None: + renderable_linestrings.append( + RenderableLineString( + linestring=linestring, + name="crosswalk", + linewidth=1, + style="-", + alpha=0.8, + color="purple", + ) + ) + + for road_area in road_area_elements.values(): + linestring = _closed_linestring(road_area.exterior_polygon.xyz) + if linestring is not None: + renderable_linestrings.append( + RenderableLineString( + linestring=linestring, + name="road_area", + linewidth=1, + style="-", + alpha=0.4, + color="green", + ) + ) + for hole in road_area.interior_holes: + linestring = _closed_linestring(hole.xyz) + if linestring is not None: + renderable_linestrings.append( + RenderableLineString( + linestring=linestring, + name="road_island", + linewidth=1, + style="-", + alpha=0.8, + color="black", + ) + ) + + for walkway in walkway_elements.values(): + linestring = _closed_linestring(walkway.polygon.xyz) + if linestring is not None: + renderable_linestrings.append( + RenderableLineString( + linestring=linestring, + name="ped_walkway", + linewidth=1, + style="-", + alpha=0.5, + color="gray", + ) + ) + + for traffic_sign in traffic_sign_elements.values(): + position = np.asarray(traffic_sign.position) + if position.shape[0] < 2: + continue + renderable_points.append( + RenderablePoint( + name="traffic_sign", + point=Point(float(position[0]), float(position[1])), + color="red", + marker="^", + size=4, + ) + ) str_tree = STRtree([r.linestring for r in renderable_linestrings]) return ShapelyMap( renderable_linestrings, + renderable_points, str_tree, ) def get_linestring_idxs_in_radius(self, center: Point, radius: float) -> np.ndarray: return self.str_tree.query(center.buffer(radius), "intersects") + def get_point_idxs_in_radius(self, center: Point, radius: float) -> np.ndarray: + return np.asarray( + [ + idx + for idx, point in enumerate(self.renderable_points) + if point.point.distance(center) <= radius + ], + dtype=int, + ) + def render( self, ax: plt.Axes, @@ -262,6 +368,11 @@ def render( if max_dist is None else self.get_linestring_idxs_in_radius(center, max_dist) ) + current_point_ids: Iterable[int] = ( + np.arange(len(self.renderable_points)) + if max_dist is None + else self.get_point_idxs_in_radius(center, max_dist) + ) # Filter for elements to plot if cfg.video.map_video.map_elements_to_plot is not None: @@ -271,24 +382,43 @@ def render( if self.renderable_linestrings[id].name in cfg.video.map_video.map_elements_to_plot ] + current_point_ids = [ + id + for id in current_point_ids + if self.renderable_points[id].name + in cfg.video.map_video.map_elements_to_plot + ] + else: + current_linestring_ids = list(current_linestrings_ids) + current_point_ids = list(current_point_ids) # Remove artists for linestrings that are no longer in the radius for linestring_id in set(self.currently_rendered_linestring_ids) - set( current_linestring_ids ): self.renderable_linestrings[linestring_id].remove_artist() + for point_id in set(self.currently_rendered_point_ids) - set(current_point_ids): + self.renderable_points[point_id].remove_artist() # Render new linestrings for linestring_id in set(current_linestring_ids) - set( self.currently_rendered_linestring_ids ): self.renderable_linestrings[linestring_id].render(ax) + for point_id in set(current_point_ids) - set(self.currently_rendered_point_ids): + self.renderable_points[point_id].render(ax) self.currently_rendered_linestring_ids = current_linestring_ids + self.currently_rendered_point_ids = current_point_ids return { "map": [ artist for linestring_id in current_linestring_ids for artist in self.renderable_linestrings[linestring_id].artists + ] + + [ + artist + for point_id in current_point_ids + for artist in self.renderable_points[point_id].artists ], } diff --git a/src/eval/tests/test_accumulator.py b/src/eval/tests/test_accumulator.py index 5c4ba920..6fa898f0 100644 --- a/src/eval/tests/test_accumulator.py +++ b/src/eval/tests/test_accumulator.py @@ -6,6 +6,13 @@ import pytest from alpasim_grpc.v0.egodriver_pb2 import DriveResponse from alpasim_grpc.v0.logging_pb2 import ActorPoses, LogEntry, RolloutMetadata +from alpasim_grpc.v0.traffic_pb2 import ( + ObjectTrajectory, + ObjectTrajectoryUpdate, + TrafficRequest, + TrafficReturn, + TrafficSessionRequest, +) from conftest import create_test_eval_config from eval.accumulator import EvalDataAccumulator @@ -123,6 +130,58 @@ def _create_driver_return() -> LogEntry: return entry +def _create_traffic_session_request() -> LogEntry: + entry = LogEntry() + entry.traffic_session_request.CopyFrom( + TrafficSessionRequest( + session_uuid="test-uuid-123", + scene_id="clipgt-test-scene", + handover_time_us=1_500_000, + logged_object_trajectories=[ + ObjectTrajectory(object_id="EGO", is_static=False), + ObjectTrajectory(object_id="TRAFFIC_1", is_static=False), + ObjectTrajectory(object_id="STATIC_1", is_static=True), + ], + ) + ) + return entry + + +def _create_traffic_request(time_query_us: int) -> LogEntry: + return LogEntry( + traffic_request=TrafficRequest( + session_uuid="test-uuid-123", + time_query_us=time_query_us, + ) + ) + + +def _trajectory_update( + object_id: str, timestamps_us: list[int] +) -> ObjectTrajectoryUpdate: + update = ObjectTrajectoryUpdate(object_id=object_id) + for idx, ts_us in enumerate(timestamps_us): + pose_at_time = update.trajectory.poses.add() + pose_at_time.timestamp_us = ts_us + pose_at_time.pose.vec.x = float(idx) + pose_at_time.pose.vec.y = float(idx + 1) + pose_at_time.pose.vec.z = 0.0 + pose_at_time.pose.quat.w = 1.0 + return update + + +def _create_traffic_return() -> LogEntry: + return LogEntry( + traffic_return=TrafficReturn( + object_trajectory_updates=[ + _trajectory_update("TRAFFIC_1", [200_000, 300_000, 400_000]), + _trajectory_update("STATIC_1", [200_000, 300_000, 400_000]), + _trajectory_update("EGO", [200_000, 300_000, 400_000]), + ] + ) + ) + + @pytest.fixture def default_eval_config() -> EvalConfig: """Create a default EvalConfig for testing.""" @@ -201,6 +260,26 @@ def test_handles_driver_request_return_pairing( accumulator.handle_message(_create_driver_request(300_000, 400_000)) assert accumulator._pending_request == (300_000, 400_000) + def test_handles_traffic_prediction_pairing_and_static_filtering( + self, default_eval_config: EvalConfig + ) -> None: + accumulator = EvalDataAccumulator(cfg=default_eval_config) + + accumulator.handle_message(_create_traffic_session_request()) + accumulator.handle_message(_create_traffic_request(200_000)) + accumulator.handle_message(_create_traffic_return()) + + assert accumulator._pending_traffic_query_us is None + assert accumulator._static_actor_ids == {"STATIC_1"} + assert accumulator._traffic_predictions.timestamps_us == [200_000] + prediction = accumulator._traffic_predictions.per_timestep_predictions[0] + assert set(prediction.object_trajectories) == {"TRAFFIC_1"} + assert prediction.object_trajectories["TRAFFIC_1"].timestamps_us.tolist() == [ + 200_000, + 300_000, + 400_000, + ] + def test_ignores_orphan_driver_return( self, default_eval_config: EvalConfig ) -> None: diff --git a/src/eval/tests/test_processing.py b/src/eval/tests/test_processing.py index adb54944..9e3393a3 100644 --- a/src/eval/tests/test_processing.py +++ b/src/eval/tests/test_processing.py @@ -22,7 +22,6 @@ aggregate_over_clips, get_avg_dist_between_incidents, ) -from eval.aggregation.telemetry import collect_driver_drive_rpc_latency from eval.schema import SceneScoreConfig @@ -665,145 +664,6 @@ def test_with_output_path( assert summary_payload["rollouts"] assert summary_payload["metrics_results"] - @patch("eval.aggregation.processing.plot_metrics_results") - def test_results_summary_json_includes_driver_latency_telemetry( - self, - mock_plot: MagicMock, - unified_metrics_df: pl.DataFrame, - temp_directory: pathlib.Path, - ) -> None: - """Test that run-level telemetry is included in aggregate outputs.""" - del mock_plot - - telemetry_summary = { - "driver_drive_rpc_duration_mean_s": 0.025, - "driver_drive_rpc_duration_sum_s": 2.5, - "driver_drive_rpc_duration_count": 100, - "source": "telemetry/metrics.prom", - } - aggregate_and_write_metrics_results_txt( - unified_metrics_df, - output_path=str(temp_directory), - run_level_metrics={ - "driver_drive_rpc_duration_mean_s": 0.025, - "driver_drive_rpc_duration_sum_s": 2.5, - "driver_drive_rpc_duration_count": 100, - }, - telemetry_summary=telemetry_summary, - ) - - summary_payload = json.loads( - (temp_directory / "results-summary.json").read_text() - ) - - assert summary_payload["telemetry"] == telemetry_summary - assert ( - summary_payload["metrics_results"][0]["driver_drive_rpc_duration_mean_s"] - == 0.025 - ) - assert ( - summary_payload["metrics_results"][0]["driver_drive_rpc_duration_count"] - == 100 - ) - - def test_collect_driver_drive_rpc_latency_from_prometheus( - self, - temp_directory: pathlib.Path, - ) -> None: - """Test driver latency extraction from runtime telemetry metrics.prom.""" - telemetry_dir = temp_directory / "job0" / "telemetry" - telemetry_dir.mkdir(parents=True) - drive_labels = 'method="drive",service="driver",tag="default",worker_id="0"' - warmup_labels = 'method="drive",service="driver",tag="warmup",worker_id="0"' - (telemetry_dir / "metrics.prom").write_text( - "\n".join( - [ - "# HELP rpc_duration_seconds RPC call duration in seconds", - "# TYPE rpc_duration_seconds histogram", - f"rpc_duration_seconds_count{{{drive_labels}}} 197.0", - f"rpc_duration_seconds_sum{{{drive_labels}}} 0.26537357791676186", - f"rpc_duration_seconds_count{{{warmup_labels}}} 10.0", - f"rpc_duration_seconds_sum{{{warmup_labels}}} 99.0", - ] - ), - encoding="utf-8", - ) - - telemetry_summary = collect_driver_drive_rpc_latency([temp_directory / "job0"]) - - assert telemetry_summary is not None - assert telemetry_summary["driver_drive_rpc_duration_count"] == 197 - assert telemetry_summary["driver_drive_rpc_duration_sum_s"] == pytest.approx( - 0.26537357791676186 - ) - assert telemetry_summary["driver_drive_rpc_duration_mean_s"] == pytest.approx( - 0.26537357791676186 / 197.0 - ) - - def test_collect_driver_drive_rpc_latency_preserves_utf8_label_escapes( - self, - temp_directory: pathlib.Path, - ) -> None: - """Test Prometheus label unescaping preserves UTF-8 text.""" - telemetry_dir = temp_directory / "job0" / "telemetry" - telemetry_dir.mkdir(parents=True) - labels = 'method="drive",service="driver",tag="dev\\nβ",worker_id="0"' - (telemetry_dir / "metrics.prom").write_text( - "\n".join( - [ - f"rpc_duration_seconds_count{{{labels}}} 2.0", - f"rpc_duration_seconds_sum{{{labels}}} 0.5", - ] - ), - encoding="utf-8", - ) - - telemetry_summary = collect_driver_drive_rpc_latency( - [temp_directory / "job0"], - tag="dev\nβ", - ) - - assert telemetry_summary is not None - assert telemetry_summary["driver_drive_rpc_duration_count"] == 2 - assert telemetry_summary["driver_drive_rpc_duration_sum_s"] == pytest.approx( - 0.5 - ) - - def test_collect_driver_drive_rpc_latency_skips_incomplete_files( - self, - temp_directory: pathlib.Path, - ) -> None: - """Test files missing sum or count are not included in latency totals.""" - complete_dir = temp_directory / "complete" / "telemetry" - complete_dir.mkdir(parents=True) - incomplete_dir = temp_directory / "incomplete" / "telemetry" - incomplete_dir.mkdir(parents=True) - labels = 'method="drive",service="driver",tag="default",worker_id="0"' - (complete_dir / "metrics.prom").write_text( - "\n".join( - [ - f"rpc_duration_seconds_count{{{labels}}} 4.0", - f"rpc_duration_seconds_sum{{{labels}}} 1.0", - ] - ), - encoding="utf-8", - ) - (incomplete_dir / "metrics.prom").write_text( - f"rpc_duration_seconds_count{{{labels}}} 100.0\n", - encoding="utf-8", - ) - - telemetry_summary = collect_driver_drive_rpc_latency( - [temp_directory / "complete", temp_directory / "incomplete"] - ) - - assert telemetry_summary is not None - assert telemetry_summary["driver_drive_rpc_duration_count"] == 4 - assert telemetry_summary["driver_drive_rpc_duration_sum_s"] == pytest.approx( - 1.0 - ) - assert telemetry_summary["files_used"] == [str(complete_dir / "metrics.prom")] - @patch("eval.aggregation.processing.plot_metrics_results") def test_results_summary_json_contains_rollout_pass_fail_and_metrics_results( self, @@ -837,7 +697,8 @@ def test_results_summary_json_contains_rollout_pass_fail_and_metrics_results( ) processed = aggregate_and_write_metrics_results_txt( - metrics_df, output_path=str(temp_directory) + metrics_df, + output_path=str(temp_directory), ) result_summary = temp_directory / "results-summary.json" diff --git a/src/grpc/alpasim_grpc/v0/sensorsim.proto b/src/grpc/alpasim_grpc/v0/sensorsim.proto index 0c6f0ad6..55d94d3e 100644 --- a/src/grpc/alpasim_grpc/v0/sensorsim.proto +++ b/src/grpc/alpasim_grpc/v0/sensorsim.proto @@ -22,11 +22,23 @@ service SensorsimService { rpc batch_render_rgb (BatchRGBRenderRequest) returns (BatchRGBRenderReturn); rpc get_version (common.Empty) returns (common.VersionId); rpc get_available_scenes (common.Empty) returns (common.AvailableScenesReturn); + rpc get_loaded_scenes (common.Empty) returns (LoadedScenesReturn); rpc get_available_cameras (AvailableCamerasRequest) returns (AvailableCamerasReturn); rpc get_available_trajectories (AvailableTrajectoriesRequest) returns (AvailableTrajectoriesReturn); rpc get_available_ego_masks (common.Empty) returns (AvailableEgoMasksReturn); } +message LoadedSceneEntry { + string scene_id = 1; + uint64 loaded_instance_count = 2; + uint64 reusable_instance_count = 3; +} + +message LoadedScenesReturn { + repeated LoadedSceneEntry scenes = 1; + uint64 loaded_instance_capacity = 2; +} + message EgoMaskId { string camera_logical_id = 1; // camera_front_wide_120fov, etc string rig_config_id = 2; // hyperion 8.0, 8.1, etc diff --git a/src/grpc/alpasim_grpc/v0/traffic.proto b/src/grpc/alpasim_grpc/v0/traffic.proto index 0a5cffa5..b83df522 100644 --- a/src/grpc/alpasim_grpc/v0/traffic.proto +++ b/src/grpc/alpasim_grpc/v0/traffic.proto @@ -12,6 +12,7 @@ service TrafficService { rpc close_session (TrafficSessionCloseRequest) returns (common.Empty); rpc simulate (TrafficRequest) returns (TrafficReturn); rpc get_metadata (common.Empty) returns (TrafficModuleMetadata); + rpc get_available_scenes (common.Empty) returns (common.AvailableScenesReturn); } message ObjectTrajectory { @@ -22,6 +23,7 @@ message ObjectTrajectory { common.Trajectory trajectory = 2; string object_id = 3; bool is_static = 4; + string label_class = 5; } message ObjectTrajectoryUpdate { @@ -33,14 +35,14 @@ message ObjectTrajectoryUpdate { message TrafficSessionRequest { string session_uuid = 1; - string map_id = 2; + string scene_id = 2; fixed64 random_seed = 3; repeated ObjectTrajectory logged_object_trajectories = 4; - // Earliest time in microseconds when the traffic model takes over control. + // Required positive time in microseconds when the traffic model takes over control. // For objects appearing later in the scene, they will be handed over after - // `traffic_models.minimum_hisotry_length` steps of log-history, but not + // `traffic_models.minimum_history_length` steps of log-history, but not // before `handover_time_us`. - optional fixed64 handover_time_us = 5; + fixed64 handover_time_us = 5; } message TrafficSessionCloseRequest { @@ -57,8 +59,12 @@ message TrafficRequest { } message TrafficReturn { - // Returns the updated trajectories for all objects in the scene. - // The last pose in the trajectory is the predicted pose at `time_query_us`. + // Returns updated trajectories for objects in the scene. + // + // Each returned trajectory must include a pose at the corresponding + // TrafficRequest.time_query_us. Dynamic objects may also include additional + // poses after time_query_us that represent the traffic model's available + // forecast horizon. Static objects may return just the time_query_us pose. // Convention: Maintains the order of objects in `TrafficSessionRequest`. repeated ObjectTrajectoryUpdate object_trajectory_updates = 1; } @@ -66,5 +72,6 @@ message TrafficReturn { message TrafficModuleMetadata { common.VersionId version_id = 1; fixed64 minimum_history_length_us = 2; - repeated string supported_map_ids = 3; + reserved 3; + reserved "supported_map_ids"; } diff --git a/src/physics/pyproject.toml b/src/physics/pyproject.toml index 60d059e9..46fc09c7 100644 --- a/src/physics/pyproject.toml +++ b/src/physics/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "alpasim-physics" -version = "1.49.0" +version = "1.53.0" description = "Physics micro-service of the Alpamayo Sim" requires-python = ">=3.11,<3.13" dependencies = [ diff --git a/src/runtime/alpasim_runtime/address_pool.py b/src/runtime/alpasim_runtime/address_pool.py index 9af93356..9f5b8755 100644 --- a/src/runtime/alpasim_runtime/address_pool.py +++ b/src/runtime/alpasim_runtime/address_pool.py @@ -7,14 +7,17 @@ Runs in the parent process and tracks which service address slots are free vs. busy. Workers never touch these pools — the parent acquires slots, attaches them to jobs, and releases them when results arrive. + +The pool is purely a token manager: it hands out ``ServiceAddress`` slots and +reclaims them on release. Scene-affine routing intelligence (which scenes are +cached where) lives in the scheduler, not here. """ from __future__ import annotations import logging +from collections import deque from dataclasses import dataclass -from queue import Empty as QueueEmpty -from queue import Queue logger = logging.getLogger(__name__) @@ -46,29 +49,47 @@ def __init__( ): self.skip = skip self._total_capacity: int = 0 - self._queue: Queue[ServiceAddress] = Queue() + self._all_addresses: frozenset[str] = ( + frozenset(addresses) if not skip else frozenset() + ) + self._slots: deque[ServiceAddress] = deque() if not skip: for addr in addresses: for _ in range(n_concurrent): - self._queue.put_nowait(ServiceAddress(addr, skip=False)) + self._slots.append(ServiceAddress(addr, skip=False)) self._total_capacity += 1 def try_acquire(self) -> ServiceAddress | None: """Non-blocking acquire. Returns None if no slots available.""" if self.skip: - # Skip pools are non-limiting: no fixed token cap. return ServiceAddress("skip", skip=True) - try: - return self._queue.get_nowait() - except QueueEmpty: + if not self._slots: return None + return self._slots.popleft() def release(self, slot: ServiceAddress) -> None: """Return a slot to the pool.""" if self.skip: - # No-op for synthetic skip slots. return - self._queue.put_nowait(slot) + self._slots.append(slot) + + def free_addresses(self) -> set[str]: + """Return unique addresses that currently have at least one free slot.""" + return {slot.address for slot in self._slots} + + def try_acquire_for_address(self, address: str) -> ServiceAddress | None: + """Acquire a free slot for a specific *address*, or ``None`` if unavailable.""" + if self.skip: + return ServiceAddress("skip", skip=True) + for i, slot in enumerate(self._slots): + if slot.address == address: + del self._slots[i] + return slot + return None + + def all_addresses(self) -> frozenset[str]: + """All configured addresses, regardless of current slot availability.""" + return self._all_addresses @property def total_capacity(self) -> int | None: @@ -80,15 +101,25 @@ def total_capacity(self) -> int | None: def try_acquire_all( pools: dict[str, AddressPool], + renderer_slot: ServiceAddress | None = None, ) -> dict[str, ServiceAddress] | None: """ Atomically acquire one slot from every pool. + When *renderer_slot* is provided, it is used directly for the + ``renderer`` pool instead of acquiring a new slot. All other pools + use regular FIFO. + If any pool has no free slot, releases all already-acquired slots - and returns None. This guarantees no address leaks on partial failure. + (including a pre-acquired *renderer_slot*) and returns ``None``. + This guarantees no address leaks on partial failure. """ acquired: dict[str, ServiceAddress] = {} + if renderer_slot is not None: + acquired["renderer"] = renderer_slot for name, pool in pools.items(): + if name in acquired: + continue slot = pool.try_acquire() if slot is None: # Roll back: release everything acquired so far diff --git a/src/runtime/alpasim_runtime/config.py b/src/runtime/alpasim_runtime/config.py index 00d342c2..b665f42c 100644 --- a/src/runtime/alpasim_runtime/config.py +++ b/src/runtime/alpasim_runtime/config.py @@ -349,6 +349,14 @@ class UserEndpointConfig: do_shutdown: bool = True +@dataclass +class RuntimePrometheusConfig: + """Runtime Prometheus settings generated by the wizard.""" + + url: str = MISSING + worker_ports: list[int] = MISSING + + @dataclass class UserSimulatorConfig: """The section of simulator config created manually by the user""" @@ -371,6 +379,17 @@ class UserSimulatorConfig: # Runtime scene provider configuration. scene_provider: SceneProviderConfig = MISSING + # When True, the scheduler routes renderer jobs to GPUs that already + # have the requested scene cached, avoiding cold-load penalties. + scene_affine_dispatch: bool = False + + # Interval in seconds for periodically re-syncing the renderer pool's + # local scene cache from NRE via get_loaded_scenes. None disables + # periodic refresh. Only effective when scene_affine_dispatch is True. + cache_refresh_interval_s: float | None = 5.0 + + prometheus: RuntimePrometheusConfig = MISSING + @dataclass class SimulatorConfig: diff --git a/src/runtime/alpasim_runtime/daemon/engine.py b/src/runtime/alpasim_runtime/daemon/engine.py index ee8249c7..c9b26a0b 100644 --- a/src/runtime/alpasim_runtime/daemon/engine.py +++ b/src/runtime/alpasim_runtime/daemon/engine.py @@ -10,6 +10,7 @@ from alpasim_grpc.v0 import logging_pb2, runtime_pb2 from alpasim_runtime.address_pool import AddressPool +from alpasim_runtime.config import RendererKind from alpasim_runtime.daemon.scheduler import DaemonScheduler, DaemonUnavailableError from alpasim_runtime.errors import UnknownSceneError from alpasim_runtime.runtime_context import ( @@ -274,19 +275,48 @@ async def startup(self) -> None: nr_workers=runtime_context.config.user.nr_workers, ) - worker_runtime = start_worker_runtime( - config=runtime_context.config, - user_config_path=self._user_config_path, - num_consumers=num_consumers_per_worker, - log_dir=self._log_dir, - eval_config=runtime_context.eval_config, - version_ids=runtime_context.version_ids, - ) + worker_runtime: WorkerRuntime | None = None + try: + worker_runtime = start_worker_runtime( + config=runtime_context.config, + user_config_path=self._user_config_path, + num_consumers=num_consumers_per_worker, + log_dir=self._log_dir, + eval_config=runtime_context.eval_config, + version_ids=runtime_context.version_ids, + ) - scheduler = DaemonScheduler( - pools=runtime_context.pools, - runtime=worker_runtime, - ) + scene_affine = runtime_context.config.user.scene_affine_dispatch + if runtime_context.config.user.renderer.kind == RendererKind.video_model: + if scene_affine: + logger.info( + "Scene-affine dispatch auto-disabled: video_model renderer " + "has no per-scene GPU cache" + ) + scene_affine = False + + scheduler = DaemonScheduler( + pools=runtime_context.pools, + runtime=worker_runtime, + scene_affine_dispatch=scene_affine, + cache_refresh_interval_s=( + runtime_context.config.user.cache_refresh_interval_s + if scene_affine + else None + ), + ) + except Exception: + if worker_runtime is not None: + await worker_runtime.stop() + raise + + try: + if scene_affine: + await scheduler.warm_start() + except BaseException: + await scheduler.shutdown(reason="warm_start failed") + await worker_runtime.stop() + raise self._version_ids = runtime_context.version_ids self._runtime_context = runtime_context diff --git a/src/runtime/alpasim_runtime/daemon/scheduler.py b/src/runtime/alpasim_runtime/daemon/scheduler.py index 1dc81fd9..8e53f0bb 100644 --- a/src/runtime/alpasim_runtime/daemon/scheduler.py +++ b/src/runtime/alpasim_runtime/daemon/scheduler.py @@ -5,8 +5,9 @@ import asyncio import logging -from collections import deque +from collections import defaultdict, deque from contextlib import suppress +from dataclasses import dataclass from typing import Protocol from alpasim_runtime.address_pool import ( @@ -17,6 +18,7 @@ ) from alpasim_runtime.config import BASE_SERVICE_NAMES from alpasim_runtime.daemon.request_store import RequestStore +from alpasim_runtime.nre_introspection import get_loaded_scenes from alpasim_runtime.worker.ipc import ( AssignedRolloutJob, JobResult, @@ -33,6 +35,15 @@ class DaemonUnavailableError(RuntimeError): pass +@dataclass +class _InFlightEntry: + """Bookkeeping for a dispatched job awaiting its result.""" + + scene_id: str + pools: dict[str, AddressPool] + acquired: dict[str, ServiceAddress] + + class WorkerRuntimeProtocol(Protocol): """Minimal interface the scheduler requires from a worker runtime.""" @@ -43,16 +54,350 @@ async def poll_result(self) -> JobResult | None: ... def check_for_crashes(self) -> None: ... +# --------------------------------------------------------------------------- +# Dispatch strategy interface + implementations +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class PendingReservation: + """Outcome of a dispatch strategy's ``try_reserve()`` call. + + Holds a pre-acquired renderer slot and the selected job. Must be + finalized via ``commit()`` or the renderer slot must be released by + the caller. + """ + + request_id: str + job: PendingRolloutJob + renderer_slot: ServiceAddress + is_affine_hit: bool + + +class DispatchStrategy(Protocol): + """Narrow interface for job-selection and pending-job bookkeeping. + + Two concrete implementations exist: ``FifoDispatch`` (strict submission + order) and ``SceneAffineDispatch`` (three-tier cache-aware priority). + + Dispatch follows a two-phase reservation/commit protocol: + + 1. ``try_reserve()`` selects a job **and** pre-acquires a renderer slot. + 2. The caller acquires the remaining service pools. + 3. ``commit(reservation)`` atomically removes the job from pending + and records it as in-flight. + + If step 2 fails, the caller releases the renderer slot itself and + the job remains pending for the next round. + """ + + @property + def pending_count(self) -> int: ... + + def add_pending(self, request_id: str, job: PendingRolloutJob) -> None: ... + + def try_reserve(self) -> PendingReservation | None: + """Select the best pending job and pre-acquire a renderer slot. + + Returns ``None`` when no jobs are pending or no renderer slot is free. + """ + ... + + def commit(self, reservation: PendingReservation) -> None: + """Finalize a reservation: remove from pending and record in-flight.""" + ... + + def on_result(self, scene_id: str, renderer_address: str, success: bool) -> None: + """Called when a job completes.""" + ... + + def drain_pending_request_ids(self) -> set[str]: + """Extract all pending request IDs and clear pending storage.""" + ... + + async def shutdown(self) -> None: + """Cancel background tasks and log summary statistics.""" + ... + + +class FifoDispatch: + """Strict submission-order dispatch -- identical to pre-affine behavior.""" + + def __init__(self, *, renderer_pool: AddressPool) -> None: + self._renderer_pool = renderer_pool + self._queue: deque[tuple[str, PendingRolloutJob]] = deque() + + @property + def pending_count(self) -> int: + return len(self._queue) + + def add_pending(self, request_id: str, job: PendingRolloutJob) -> None: + self._queue.append((request_id, job)) + + def try_reserve(self) -> PendingReservation | None: + if not self._queue: + return None + slot = self._renderer_pool.try_acquire() + if slot is None: + return None + request_id, job = self._queue[0] + return PendingReservation(request_id, job, slot, is_affine_hit=False) + + def commit(self, reservation: PendingReservation) -> None: + self._queue.popleft() + + def on_result(self, scene_id: str, renderer_address: str, success: bool) -> None: + return + + def drain_pending_request_ids(self) -> set[str]: + ids = {req_id for req_id, _ in self._queue} + self._queue.clear() + return ids + + async def shutdown(self) -> None: + pass + + +class SceneAffineDispatch: + """Three-tier cache-aware dispatch strategy. + + Tier 1 -- Affine: pick a job whose scene is already cached *or + in-flight* on a free renderer GPU. Likely warm-cache hit. + + Tier 2 -- New scene: pick a job for a scene not yet cached by + any GPU. Maximises cache diversity across GPUs. + + Tier 3 -- Fallback: pick any pending job. + """ + + def __init__( + self, + *, + renderer_pool: AddressPool, + cache_refresh_interval_s: float | None = 5.0, + ) -> None: + self._renderer_pool = renderer_pool + self._cache_refresh_interval_s = cache_refresh_interval_s + + self._pending_by_scene: dict[str, deque[tuple[str, PendingRolloutJob]]] = {} + # In-flight scene<->address tracking so the dispatch tiers can + # see what's been dispatched but not yet released. + # Counts (not sets) because multiple jobs for the same scene + # can be on the same address (n_concurrent > 1). + self._inflight_addr_scenes: dict[str, dict[str, int]] = defaultdict( + lambda: defaultdict(int) + ) # address --> {scene --> count} + + # Per-address set of known cached scenes (written only by sync_scene_cache). + self._address_scenes: dict[str, set[str]] = {} + + self._affine_hits = 0 + self._total_dispatched = 0 + + self._cache_refresh_task: asyncio.Task[None] | None = None + + def sync_scene_cache(self, address: str, scene_ids: list[str]) -> None: + """Overwrite the scene set for *address* with the server's authoritative list.""" + self._address_scenes[address] = set(scene_ids) + + def cached_scenes(self, address: str) -> set[str]: + """Return the scenes known to be cached on *address*.""" + return self._address_scenes.get(address, set()) + + def is_scene_cached(self, scene_id: str) -> bool: + """True if at least one address has *scene_id* in its cache.""" + return any(scene_id in scenes for scenes in self._address_scenes.values()) + + async def warm_start(self) -> None: + """Perform initial cache sync from all NRE addresses, then start refresh loop. + + Raises ``IntrospectionNotSupportedError`` if any NRE server returns + UNIMPLEMENTED, allowing fast failure at startup when the NRE image is + incompatible with scene-affine dispatch. + """ + if self._renderer_pool.skip or not self._renderer_pool.all_addresses(): + return + + snapshot = await self._refresh_cache_once(raise_on_unimplemented=True) + if snapshot: + total_scenes = sum(len(s) for s in snapshot.values()) + logger.info( + "Warm-started %d renderer address(es) with %d cached scene(s)", + len(snapshot), + total_scenes, + ) + + if self._cache_refresh_interval_s is not None: + self._cache_refresh_task = asyncio.create_task(self._cache_refresh_loop()) + + # -- pending-job bookkeeping -- + + @property + def pending_count(self) -> int: + return sum(len(q) for q in self._pending_by_scene.values()) + + def add_pending(self, request_id: str, job: PendingRolloutJob) -> None: + scene = job.scene_id + if scene not in self._pending_by_scene: + self._pending_by_scene[scene] = deque() + self._pending_by_scene[scene].append((request_id, job)) + + def _pop_pending(self, scene_id: str) -> tuple[str, PendingRolloutJob]: + q = self._pending_by_scene[scene_id] + entry = q.popleft() + if not q: + del self._pending_by_scene[scene_id] + return entry + + # -- job selection (three-tier priority) -- + + def _is_scene_inflight(self, scene_id: str) -> bool: + """True if *scene_id* is in-flight on any renderer address.""" + return any(scene_id in scenes for scenes in self._inflight_addr_scenes.values()) + + def try_reserve(self) -> PendingReservation | None: + # Tier 1: free GPU slot whose cached *or in-flight* scene has a + # pending job. Iterates free addresses (tiny, ~1-8) x scenes per + # address (small), NOT pending scenes (could be thousands). + for address in self._renderer_pool.free_addresses(): + scenes: set[str] = set(self.cached_scenes(address)) + inflight = self._inflight_addr_scenes.get(address) + if inflight: + scenes.update(inflight) + for scene in scenes: + if scene in self._pending_by_scene: + slot = self._renderer_pool.try_acquire_for_address(address) + if slot is None: + break + request_id, job = self._pending_by_scene[scene][0] + return PendingReservation(request_id, job, slot, is_affine_hit=True) + + # Tier 2: pending scene not yet cached or in-flight on any GPU. + for scene in self._pending_by_scene: + if not self.is_scene_cached(scene) and not self._is_scene_inflight(scene): + slot = self._renderer_pool.try_acquire() + if slot is None: + return None + request_id, job = self._pending_by_scene[scene][0] + return PendingReservation(request_id, job, slot, is_affine_hit=False) + + # Tier 3: fallback -- any pending job, FIFO renderer slot. + if not self._pending_by_scene: + return None + slot = self._renderer_pool.try_acquire() + if slot is None: + return None + scene_deque = next(iter(self._pending_by_scene.values())) + request_id, job = scene_deque[0] + return PendingReservation(request_id, job, slot, is_affine_hit=False) + + # -- dispatch / result hooks -- + + def commit(self, reservation: PendingReservation) -> None: + self._pop_pending(reservation.job.scene_id) + self._inflight_addr_scenes[reservation.renderer_slot.address][ + reservation.job.scene_id + ] += 1 + if reservation.is_affine_hit: + self._affine_hits += 1 + self._total_dispatched += 1 + + def on_result(self, scene_id: str, renderer_address: str, success: bool) -> None: + addr_scenes = self._inflight_addr_scenes.get(renderer_address) + if addr_scenes: + addr_scenes[scene_id] -= 1 + if addr_scenes[scene_id] <= 0: + del addr_scenes[scene_id] + if not addr_scenes: + del self._inflight_addr_scenes[renderer_address] + + def drain_pending_request_ids(self) -> set[str]: + ids = {req_id for jobs in self._pending_by_scene.values() for req_id, _ in jobs} + self._pending_by_scene.clear() + return ids + + # -- lifecycle -- + + async def shutdown(self) -> None: + if self._cache_refresh_task is not None: + self._cache_refresh_task.cancel() + with suppress(asyncio.CancelledError): + await self._cache_refresh_task + + if self._total_dispatched > 0: + pct = self._affine_hits / self._total_dispatched * 100 + logger.info( + "Scene-affine dispatch summary: %d/%d affine hits (%.1f%%)", + self._affine_hits, + self._total_dispatched, + pct, + ) + + async def _refresh_cache_once( + self, *, raise_on_unimplemented: bool = False + ) -> dict[str, frozenset[str]]: + """Query all renderer addresses and sync the local cache mirror. + + Returns a mapping of address → frozenset of cached scene IDs for each + address that responded successfully. + """ + unique_addresses = sorted(self._renderer_pool.all_addresses()) + snapshot: dict[str, frozenset[str]] = {} + for address in unique_addresses: + loaded = await get_loaded_scenes( + address, raise_on_unimplemented=raise_on_unimplemented + ) + if loaded is None: + continue + scene_ids = list(loaded.keys()) + self.sync_scene_cache(address, scene_ids) + snapshot[address] = frozenset(scene_ids) + return snapshot + + async def _cache_refresh_loop(self) -> None: + """Periodically re-sync the scene cache from NRE.""" + assert self._cache_refresh_interval_s is not None + num_addresses = len(self._renderer_pool.all_addresses()) + logger.info( + "Cache refresh loop started: %d address(es), interval=%.1fs", + num_addresses, + self._cache_refresh_interval_s, + ) + prev_snapshots: dict[str, frozenset[str]] = {} + while True: + await asyncio.sleep(self._cache_refresh_interval_s) + current_snapshots = await self._refresh_cache_once() + for address, current in current_snapshots.items(): + prev = prev_snapshots.get(address, frozenset()) + if current != prev: + added = current - prev + removed = prev - current + logger.info( + "Cache changed on %s: +%d scene(s) %s, -%d scene(s) %s", + address, + len(added), + sorted(added) if added else "[]", + len(removed), + sorted(removed) if removed else "[]", + ) + prev_snapshots = current_snapshots + + +# --------------------------------------------------------------------------- +# Scheduler +# --------------------------------------------------------------------------- + + class DaemonScheduler: """Job scheduler that manages dispatch of simulation jobs to workers. - Maintains a global pending queue and uses a greedy acquire-all strategy: - for each pending job, it attempts to acquire one slot from every required - service pool simultaneously. If any pool is exhausted, dispatch pauses until - a running job completes and releases its slots. + Maintains pending jobs and uses a greedy acquire-all strategy: for each + dispatch round it selects the best pending job via a pluggable + ``DispatchStrategy``, acquires one slot from every service pool, and + submits the job to the worker runtime. - Requests may optionally override specific pools (e.g. a per-request driver - pool) via ``submit_request``. + Requests may optionally override specific pools (e.g. a per-request + driver pool) via ``submit_request``. """ def __init__( @@ -60,19 +405,44 @@ def __init__( *, pools: dict[str, AddressPool], runtime: WorkerRuntimeProtocol, + scene_affine_dispatch: bool = True, + cache_refresh_interval_s: float | None = 5.0, ) -> None: self._pools = pools self._runtime = runtime self._required_service_names = (*BASE_SERVICE_NAMES, "renderer") self._request_store = RequestStore() - self._global_pending: deque[tuple[str, PendingRolloutJob]] = deque() - self._in_flight: dict[ - str, tuple[dict[str, AddressPool], dict[str, ServiceAddress]] - ] = {} + + renderer_pool = pools["renderer"] + if scene_affine_dispatch: + self._strategy: DispatchStrategy = SceneAffineDispatch( + renderer_pool=renderer_pool, + cache_refresh_interval_s=cache_refresh_interval_s, + ) + logger.info("Scene-affine dispatch ENABLED for renderer") + else: + self._strategy = FifoDispatch(renderer_pool=renderer_pool) + logger.info("Scene-affine dispatch DISABLED") + + self._in_flight: dict[str, _InFlightEntry] = {} self._request_pools: dict[str, dict[str, AddressPool]] = {} self._accepting_requests = True self._dispatch_loop_task = asyncio.create_task(self._dispatch_loop()) + async def warm_start(self) -> None: + """Seed the dispatch strategy's cache from NRE servers. + + Only meaningful for SceneAffineDispatch; no-op for FifoDispatch. + Raises ``IntrospectionNotSupportedError`` if the NRE image does not + support GetLoadedScenes. + """ + if isinstance(self._strategy, SceneAffineDispatch): + await self._strategy.warm_start() + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + async def submit_request( self, request_id: str, @@ -82,6 +452,9 @@ async def submit_request( ) -> None: """Register a new simulation request and enqueue its jobs for dispatch. + Jobs are grouped by scene_id before enqueuing so that consecutive + dispatches for the same scene benefit from renderer cache affinity. + If *driver_pool* is provided, it overrides the global driver pool for all jobs belonging to this request. After enqueuing, immediately attempts to dispatch as many jobs as possible. @@ -96,8 +469,10 @@ async def submit_request( self._request_pools[request_id] = {**self._pools, "driver": driver_pool} await self._request_store.register_request(request_id, expected_jobs=len(jobs)) + for job in jobs: - self._global_pending.append((request_id, job)) + self._strategy.add_pending(request_id, job) + await self.dispatch_once() async def wait_request(self, request_id: str) -> list[JobResult]: @@ -117,8 +492,7 @@ async def shutdown(self, *, reason: str) -> None: """ self._accepting_requests = False - pending_request_ids = {request_id for request_id, _ in self._global_pending} - self._global_pending.clear() + pending_request_ids = self._strategy.drain_pending_request_ids() for request_id in pending_request_ids: self._request_pools.pop(request_id, None) self._request_store.fail_request(request_id, reason) @@ -127,30 +501,39 @@ async def shutdown(self, *, reason: str) -> None: with suppress(asyncio.CancelledError): await self._dispatch_loop_task + await self._strategy.shutdown() + async def dispatch_once(self) -> None: - """Greedily dispatch as many pending jobs as possible. + """Greedily dispatch pending jobs via the active strategy. - Peeks at the head of the queue, attempts to acquire all required - service slots, and submits the job to the worker runtime. Repeats - until the queue is empty or no service slots are available. + The strategy reserves the best job *and* pre-acquires the renderer + slot. ``try_acquire_all`` then acquires the remaining pools, + rolling back the renderer slot on failure. """ - while self._global_pending: - request_id, pending_job = self._global_pending[0] # peek - pools = self._request_pools.get(request_id, self._pools) + while self._strategy.pending_count > 0: + reservation = self._strategy.try_reserve() + if reservation is None: + return + + pools = self._request_pools.get(reservation.request_id, self._pools) required_pools = { name: pools[name] for name in self._required_service_names } - acquired = try_acquire_all(required_pools) + + acquired = try_acquire_all( + required_pools, renderer_slot=reservation.renderer_slot + ) if acquired is None: + # try_acquire_all already released renderer_slot. return - self._global_pending.popleft() # consume after successful acquire + self._strategy.commit(reservation) assigned = AssignedRolloutJob( - request_id=request_id, - job_id=pending_job.job_id, - scene_id=pending_job.scene_id, - rollout_spec_index=pending_job.rollout_spec_index, + request_id=reservation.request_id, + job_id=reservation.job.job_id, + scene_id=reservation.job.scene_id, + rollout_spec_index=reservation.job.rollout_spec_index, endpoints=ServiceEndpoints( driver=acquired["driver"], renderer=acquired["renderer"], @@ -158,17 +541,24 @@ async def dispatch_once(self) -> None: trafficsim=acquired["trafficsim"], controller=acquired["controller"], ), - session_uuid=pending_job.session_uuid, + session_uuid=reservation.job.session_uuid, ) self._runtime.submit_assigned_job(assigned) - self._in_flight[assigned.job_id] = (required_pools, acquired) + self._in_flight[assigned.job_id] = _InFlightEntry( + scene_id=reservation.job.scene_id, + pools=required_pools, + acquired=acquired, + ) def on_result(self, result: JobResult) -> None: entry = self._in_flight.pop(result.job_id, None) if entry is None: raise RuntimeError(f"Unknown job_id in result queue: {result.job_id}") - pools, acquired = entry - release_all(pools, acquired) + + renderer_addr = entry.acquired["renderer"].address + self._strategy.on_result(entry.scene_id, renderer_addr, result.success) + + release_all(entry.pools, entry.acquired) self._request_store.record_result(result) try: diff --git a/src/runtime/alpasim_runtime/delay_buffer.py b/src/runtime/alpasim_runtime/delay_buffer.py index 6df4da6d..8897e637 100644 --- a/src/runtime/alpasim_runtime/delay_buffer.py +++ b/src/runtime/alpasim_runtime/delay_buffer.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# Copyright (c) 2025 NVIDIA Corporation +# Copyright (c) 2025-2026 NVIDIA Corporation from collections import deque from typing import Any diff --git a/src/runtime/alpasim_runtime/event_loop.py b/src/runtime/alpasim_runtime/event_loop.py index d663a0b6..70ff851b 100644 --- a/src/runtime/alpasim_runtime/event_loop.py +++ b/src/runtime/alpasim_runtime/event_loop.py @@ -509,8 +509,6 @@ async def run(self) -> ScenarioEvalResult | None: ) # Create traffic session - gt_ego_aabb_trajectory = _build_traffic_session_trajectory(self.unbound) - await async_stack.enter_async_context( self.trafficsim.rollout_session( uuid=str(self.unbound.rollout_uuid), @@ -519,8 +517,12 @@ async def run(self) -> ScenarioEvalResult | None: traffic_objs=self.unbound.traffic_objs, scene_id=self.unbound.scene_id, ego_aabb=self.unbound.ego_aabb, - gt_ego_aabb_trajectory=gt_ego_aabb_trajectory, + gt_ego_aabb_trajectory=_build_traffic_session_trajectory( + self.unbound + ), start_timestamp_us=self.unbound.egomotion_context_start_us, + force_gt_duration_us=self.unbound.force_gt_duration_us, + control_timestep_us=self.unbound.control_timestep_us, ), ) ) diff --git a/src/runtime/alpasim_runtime/nre_introspection.py b/src/runtime/alpasim_runtime/nre_introspection.py new file mode 100644 index 00000000..86f0a6cd --- /dev/null +++ b/src/runtime/alpasim_runtime/nre_introspection.py @@ -0,0 +1,73 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation + +"""NRE server introspection for scene-affine dispatch. + +Requires an NRE build that implements the ``GetLoadedScenes`` RPC +(added in NRE MR !3709). Returns ``None`` on transient gRPC failures so +callers can distinguish "query failed" from "genuinely no cached scenes". +""" + +from __future__ import annotations + +import logging + +from alpasim_grpc.v0.common_pb2 import Empty +from alpasim_grpc.v0.sensorsim_pb2_grpc import SensorsimServiceStub + +import grpc +import grpc.aio + +logger = logging.getLogger(__name__) + +_INTROSPECTION_TIMEOUT_S = 15.0 + + +class IntrospectionNotSupportedError(Exception): + """Raised when the NRE server does not implement GetLoadedScenes.""" + + pass + + +async def get_loaded_scenes( + address: str, + *, + raise_on_unimplemented: bool = False, +) -> dict[str, int] | None: + """Query an NRE server for its currently loaded scene counts. + + Returns a ``{scene_id: loaded_instance_count}`` dict on success, or + ``None`` if the RPC call fails for a transient reason (so callers can + distinguish "no cached scenes" from "query failed"). + + Args: + address: gRPC address of the NRE server. + raise_on_unimplemented: If True, raises ``IntrospectionNotSupportedError`` + when the server returns UNIMPLEMENTED instead of silently returning None. + Use at startup to fail fast when scene-affine dispatch is enabled but + the NRE image doesn't support introspection. + """ + channel = grpc.aio.insecure_channel(address) + try: + stub = SensorsimServiceStub(channel) + response = await stub.get_loaded_scenes( + Empty(), timeout=_INTROSPECTION_TIMEOUT_S + ) + return { + entry.scene_id: entry.loaded_instance_count for entry in response.scenes + } + except grpc.aio.AioRpcError as e: + if raise_on_unimplemented and e.code() == grpc.StatusCode.UNIMPLEMENTED: + raise IntrospectionNotSupportedError( + f"NRE server at {address} does not support GetLoadedScenes " + f"(UNIMPLEMENTED). Scene-affine dispatch requires an NRE image " + f"with this RPC. Either upgrade the NRE image or disable " + f"scene_affine_dispatch." + ) from e + logger.warning("get_loaded_scenes failed on %s: %s", address, e.details()) + return None + except Exception as e: + logger.warning("get_loaded_scenes unexpected error on %s: %s", address, e) + return None + finally: + await channel.close() diff --git a/src/runtime/alpasim_runtime/replay_services/traffic_replay_service.py b/src/runtime/alpasim_runtime/replay_services/traffic_replay_service.py index 4e37ac8e..1f541c53 100644 --- a/src/runtime/alpasim_runtime/replay_services/traffic_replay_service.py +++ b/src/runtime/alpasim_runtime/replay_services/traffic_replay_service.py @@ -33,9 +33,6 @@ def get_metadata( version_id = self.get_version(request, context) metadata.version_id.CopyFrom(version_id) metadata.minimum_history_length_us = 1000000 # 1 second default - - map_id = self.asl_reader.get_map_id() - metadata.supported_map_ids.append(map_id) return metadata def simulate( diff --git a/src/runtime/alpasim_runtime/scene_loader.py b/src/runtime/alpasim_runtime/scene_loader.py index 6cbcefdf..1e28b752 100644 --- a/src/runtime/alpasim_runtime/scene_loader.py +++ b/src/runtime/alpasim_runtime/scene_loader.py @@ -135,6 +135,52 @@ def __init__( max_cache_size=max_cache_size, ) + @classmethod + def from_path( + cls, + data_path: str | Path, + *, + smooth_trajectories: bool, + max_cache_size: int | None = None, + ) -> ArtifactSceneProvider: + """Build an artifact-backed provider from a USDZ file or directory.""" + path = Path(data_path) + glob_query = str(path) if path.suffix == ".usdz" else str(path / "**/*.usdz") + discovered = Artifact.discover_from_glob( + glob_query, + smooth_trajectories=smooth_trajectories, + ) + logger.info( + "Discovered %d USDZ scenes from %s", + len(discovered), + glob_query, + ) + + artifact_paths: dict[str, str] = {} + scene_infos: list[SceneInfo] = [] + for scene_id, artifact in discovered.items(): + existing = artifact_paths.get(scene_id) + if existing is not None: + raise ValueError( + f"Duplicate scene_id {scene_id!r} discovered from USDZ sources " + f"{existing!r} and {artifact.source!r}" + ) + artifact_paths[scene_id] = artifact.source + scene_infos.append( + SceneInfo( + scene_id=scene_id, + provider_kind="usdz", + metadata=artifact.metadata, + ) + ) + + return cls( + artifact_paths, + sorted(scene_infos, key=lambda scene_info: scene_info.scene_id), + smooth_trajectories=smooth_trajectories, + max_cache_size=max_cache_size, + ) + @property def provider_kind(self) -> str: return "usdz" @@ -182,6 +228,10 @@ def get_data_source(self, scene_id: str) -> SceneDataSource: def num_scenes(self) -> int: return len(self._scene_ids) + @property + def scene_ids(self) -> set[str]: + return set(self._scene_ids) + @property def scene_infos(self) -> list[SceneInfo]: return self._provider.scene_infos @@ -233,39 +283,8 @@ def _build_artifact_scene_provider( if usdz_provider_config.data_dir is None: raise ValueError("scene_provider.usdz.data_dir is required") - path = Path(usdz_provider_config.data_dir) - glob_query = str(path) if path.suffix == ".usdz" else str(path / "**/*.usdz") - discovered = Artifact.discover_from_glob( - glob_query, - smooth_trajectories=False, - ) - logger.info( - "Discovered %d USDZ scenes from %s", - len(discovered), - glob_query, - ) - - artifact_paths: dict[str, str] = {} - scene_infos: list[SceneInfo] = [] - for scene_id, artifact in discovered.items(): - existing = artifact_paths.get(scene_id) - if existing is not None: - raise ValueError( - f"Duplicate scene_id {scene_id!r} discovered from USDZ sources " - f"{existing!r} and {artifact.source!r}" - ) - artifact_paths[scene_id] = artifact.source - scene_infos.append( - SceneInfo( - scene_id=scene_id, - provider_kind="usdz", - metadata=artifact.metadata, - ) - ) - - return ArtifactSceneProvider( - artifact_paths, - sorted(scene_infos, key=lambda scene_info: scene_info.scene_id), + return ArtifactSceneProvider.from_path( + usdz_provider_config.data_dir, smooth_trajectories=user_config.smooth_trajectories, max_cache_size=usdz_provider_config.artifact_cache_size, ) diff --git a/src/runtime/alpasim_runtime/services/session_configs.py b/src/runtime/alpasim_runtime/services/session_configs.py index f587efeb..fcb5aa56 100644 --- a/src/runtime/alpasim_runtime/services/session_configs.py +++ b/src/runtime/alpasim_runtime/services/session_configs.py @@ -37,6 +37,8 @@ class TrafficSessionConfig: ego_aabb: AABB gt_ego_aabb_trajectory: Trajectory start_timestamp_us: int + force_gt_duration_us: int + control_timestep_us: int @dataclass(frozen=True) diff --git a/src/runtime/alpasim_runtime/services/traffic_service.py b/src/runtime/alpasim_runtime/services/traffic_service.py index 522a5f6b..50b5816c 100644 --- a/src/runtime/alpasim_runtime/services/traffic_service.py +++ b/src/runtime/alpasim_runtime/services/traffic_service.py @@ -7,7 +7,6 @@ import logging import random -import re from typing import Type import numpy as np @@ -31,19 +30,6 @@ logger = logging.getLogger(__name__) -def _extract_map_id(scene_id: str) -> str: - """Extract map ID from scene ID.""" - # Assuming scene_id clipgt- - pattern = r"^clipgt-([0-9a-fA-F-]{36})$" - match = re.match(pattern, scene_id) - if match is None: - # Fallback to more generic pattern - match = re.search(r"([a-fA-F0-9-]+)$", scene_id) - if match is None: - raise RuntimeError("scene_id does not contain a valid map ID") - return match.group(1) - - class TrafficService(ServiceBase[TrafficServiceStub]): """ Traffic service implementation that handles both real and skip modes. @@ -69,9 +55,12 @@ async def _initialize_session(self, session_info: SessionInfo) -> None: ego_aabb = cfg.ego_aabb gt_ego_aabb_trajectory = cfg.gt_ego_aabb_trajectory start_timestamp_us = cfg.start_timestamp_us - - # Extract map ID from scene ID - map_id = _extract_map_id(scene_id) + # Traffic responses are committed on the following StepEvent, so keep + # CATK on logged traffic state through the last frame that runtime + # renders from force-GT. + handover_time_us = ( + start_timestamp_us + cfg.force_gt_duration_us + cfg.control_timestep_us + ) # Convert traffic objects to ObjectTrajectory format logged_object_trajectories = [] @@ -94,16 +83,17 @@ async def _initialize_session(self, session_info: SessionInfo) -> None: trajectory=trajectory_to_grpc(obj.trajectory), aabb=obj.aabb.to_grpc(), is_static=obj.is_static, + label_class=obj.label_class, ) ) # Create session request session_request = TrafficSessionRequest( session_uuid=session_info.uuid, - map_id=map_id, + scene_id=scene_id, random_seed=random.randint(0, 2**32 - 1), logged_object_trajectories=logged_object_trajectories, - handover_time_us=start_timestamp_us + int(1e6), # Add 1 second for warm-up + handover_time_us=handover_time_us, ) # Log and start session diff --git a/src/runtime/alpasim_runtime/simulate/__main__.py b/src/runtime/alpasim_runtime/simulate/__main__.py index bf44131e..6b1cc578 100644 --- a/src/runtime/alpasim_runtime/simulate/__main__.py +++ b/src/runtime/alpasim_runtime/simulate/__main__.py @@ -25,7 +25,6 @@ from alpasim_runtime.daemon.engine import DaemonEngine from alpasim_runtime.runtime_context import parse_simulator_config from alpasim_runtime.telemetry.plot_metrics import generate_metrics_plot -from alpasim_runtime.telemetry.utils import merge_metrics_files from alpasim_runtime.validation import validate_array_job_config from alpasim_utils.yaml_utils import typed_parse_config @@ -40,7 +39,7 @@ def get_run_name(log_dir: str) -> str: run_metadata_path = os.path.join(log_dir, "run_metadata.yaml") with open(run_metadata_path, "r") as f: run_metadata = yaml.safe_load(f) - return run_metadata.get("run_name") + return run_metadata["run_name"] def _failed_rollouts_from_returns( @@ -64,6 +63,29 @@ def _failed_rollouts_from_returns( return failed_rollouts +def _write_metrics_artifact_error(prometheus_dir: Path, exc: BaseException) -> None: + prometheus_dir.mkdir(parents=True, exist_ok=True) + error_path = prometheus_dir / "metrics_plot_error.txt" + error_path.write_text(f"{type(exc).__name__}: {exc}", encoding="utf-8") + logger.warning("Telemetry plot generation failed: %s", exc) + + +def _generate_metrics_artifacts( + *, + prometheus_url: str, + log_dir: Path, +) -> None: + """Generate best-effort runtime metrics artifacts from local Prometheus.""" + prometheus_dir = log_dir / "prometheus" + try: + generate_metrics_plot( + prometheus_url=prometheus_url, + output_path=log_dir / "metrics_plot.png", + ) + except Exception as exc: + _write_metrics_artifact_error(prometheus_dir, exc) + + def create_arg_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser() @@ -75,7 +97,7 @@ def create_arg_parser() -> argparse.ArgumentParser: "--log-dir", type=str, required=True, - help="Root directory for all simulation outputs (rollouts/, telemetry/, txt-logs/)", + help="Root directory for all simulation outputs (rollouts/, prometheus/, txt-logs/)", ) parser.add_argument( @@ -176,7 +198,6 @@ async def run_simulation(args: argparse.Namespace) -> bool: # Derive output directories from log_dir rollouts_dir = os.path.join(args.log_dir, "rollouts") - telemetry_dir = os.path.join(args.log_dir, "telemetry") # Validate nr_workers if config.user.nr_workers < 1: @@ -217,16 +238,10 @@ async def run_simulation(args: argparse.Namespace) -> bool: if len(failed) > 3: logger.error(" ... and %d more", len(failed) - 3) - # Merge telemetry files - merge_metrics_files(telemetry_dir) - - # Generate telemetry visualization plot - output_path = generate_metrics_plot( - metrics_path=Path(telemetry_dir) / "metrics.prom", - output_path=Path(args.log_dir) / "metrics_plot.png", - run_name=get_run_name(args.log_dir), + _generate_metrics_artifacts( + prometheus_url=config.user.prometheus.url, + log_dir=Path(args.log_dir), ) - logger.info("Generated telemetry metrics plot: %s", output_path) success = all_rollouts_successful diff --git a/src/runtime/alpasim_runtime/telemetry/metrics_analysis.ipynb b/src/runtime/alpasim_runtime/telemetry/metrics_analysis.ipynb deleted file mode 100644 index 148ec555..00000000 --- a/src/runtime/alpasim_runtime/telemetry/metrics_analysis.ipynb +++ /dev/null @@ -1,295 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Metrics Analysis Notebook\n", - "\n", - "This notebook loads Prometheus `.prom` files and generates various plots for analyzing simulation performance:\n", - "\n", - "1. **RPC Duration Distribution** - Histogram of RPC durations by method\n", - "2. **RPC Blocking Time Distribution** - Histogram of blocking times per service\n", - "3. **Queue Depth per Service** - Histogram of concurrent RPCs per service\n", - "4. **Rollout Duration Distribution** - How long each rollout takes\n", - "5. **Step Duration Distribution** - How long each simulation step takes\n", - "6. **CPU Utilization** - Box plot of CPU usage for high-utilization processes\n", - "7. **GPU Utilization & Memory** - Box plots of GPU metrics\n" - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/home/migl/workspace/alpasim2/src/runtime/alpasim_runtime/telemetry/resources.py:29: FutureWarning: The pynvml package is deprecated. Please install nvidia-ml-py instead. If you did not install pynvml directly, please report this to the maintainers of the package that installed pynvml for you.\n", - " import pynvml\n" - ] - } - ], - "source": [ - "from pathlib import Path\n", - "\n", - "import matplotlib.pyplot as plt\n", - "import polars as pl\n", - "\n", - "from alpasim_runtime.telemetry.plot_metrics import (\n", - " METHODS_TO_PLOT,\n", - " generate_metrics_plot,\n", - " _extract_histograms,\n", - " _extract_gauges,\n", - " _build_histograms_dataframe,\n", - " _build_gauges_dataframe,\n", - " _compute_summary_stats,\n", - ")\n", - "\n", - "# Configure display\n", - "pl.Config.set_tbl_rows(100)\n", - "%matplotlib inline\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Loading metrics from: /home/migl/workspace/alpasim2/.wizard/metrics/metrics.prom\n", - "Loaded 128 histograms and 274 gauges\n", - "\n", - "Histogram types: ['rollout_duration_seconds', 'rpc_blocking_seconds', 'rpc_duration_seconds', 'rpc_queue_depth_at_start', 'step_duration_seconds']\n", - "Gauge types: ['event_loop_idle_seconds_total', 'event_loop_poll_seconds_total', 'event_loop_work_seconds_total', 'gpu_memory_total_bytes', 'gpu_memory_used_bytes', 'gpu_utilization', 'process_cpu_utilization', 'rollout_duration_seconds_created', 'rpc_blocking_seconds_created', 'rpc_duration_seconds_created', 'rpc_queue_depth_at_start_created', 'simulation_rollout_count', 'simulation_seconds_per_rollout', 'simulation_total_seconds', 'step_duration_seconds_created']\n" - ] - } - ], - "source": [ - "# Load metrics data\n", - "# Update this path to point to your metrics file\n", - "METRICS_PATH = Path(\"~/workspace/alpasim2/.wizard/telemetry/metrics.prom\").expanduser()\n", - "\n", - "# Alternative: use absolute path\n", - "# METRICS_PATH = Path(\"/path/to/your/metrics.prom\")\n", - "\n", - "print(f\"Loading metrics from: {METRICS_PATH.resolve()}\")\n", - "\n", - "histograms = _extract_histograms(METRICS_PATH)\n", - "gauges = _extract_gauges(METRICS_PATH)\n", - "\n", - "print(f\"Loaded {len(histograms)} histograms and {len(gauges)} gauges\")\n", - "\n", - "# Show available metric types\n", - "histogram_types = sorted(set(h.name for h in histograms))\n", - "gauge_types = sorted(set(g.name for g in gauges))\n", - "\n", - "print(f\"\\nHistogram types: {histogram_types}\")\n", - "print(f\"Gauge types: {gauge_types}\")" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Histograms DataFrame shape: (2364, 9)\n", - "Gauges DataFrame shape: (146, 8)\n" - ] - } - ], - "source": [ - "# Build DataFrames\n", - "histograms_df = _build_histograms_dataframe(histograms)\n", - "gauges_df = _build_gauges_dataframe(gauges)\n", - "\n", - "print(f\"Histograms DataFrame shape: {histograms_df.shape}\")\n", - "print(f\"Gauges DataFrame shape: {gauges_df.shape}\")" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Async worker idle percentage: 83.66%\n", - "Sim seconds per rollout: 2.84\n" - ] - } - ], - "source": [ - "# Compute summary stats\n", - "idle_percentage, sim_seconds_per_rollout = _compute_summary_stats(gauges_df)\n", - "\n", - "print(f\"Async worker idle percentage: {idle_percentage:.2%}\")\n", - "print(f\"Sim seconds per rollout: {sim_seconds_per_rollout:.2f}\")" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "14:12:10.106 INFO:\tLoading metrics from: /home/migl/workspace/alpasim2/.wizard/metrics/metrics.prom\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "14:12:10.207 INFO:\tUsing categorical units to plot a list of strings that are all parsable as floats or dates. If these strings should be plotted as numbers, cast to the appropriate data type before plotting.\n", - "14:12:10.210 INFO:\tUsing categorical units to plot a list of strings that are all parsable as floats or dates. If these strings should be plotted as numbers, cast to the appropriate data type before plotting.\n", - "14:12:10.249 INFO:\tUsing categorical units to plot a list of strings that are all parsable as floats or dates. If these strings should be plotted as numbers, cast to the appropriate data type before plotting.\n", - "14:12:10.251 INFO:\tUsing categorical units to plot a list of strings that are all parsable as floats or dates. If these strings should be plotted as numbers, cast to the appropriate data type before plotting.\n", - "14:12:10.359 INFO:\tUsing categorical units to plot a list of strings that are all parsable as floats or dates. If these strings should be plotted as numbers, cast to the appropriate data type before plotting.\n", - "14:12:10.361 INFO:\tUsing categorical units to plot a list of strings that are all parsable as floats or dates. If these strings should be plotted as numbers, cast to the appropriate data type before plotting.\n", - "14:12:10.606 INFO:\tUsing categorical units to plot a list of strings that are all parsable as floats or dates. If these strings should be plotted as numbers, cast to the appropriate data type before plotting.\n", - "14:12:10.608 INFO:\tUsing categorical units to plot a list of strings that are all parsable as floats or dates. If these strings should be plotted as numbers, cast to the appropriate data type before plotting.\n", - "14:12:10.788 INFO:\tUsing categorical units to plot a list of strings that are all parsable as floats or dates. If these strings should be plotted as numbers, cast to the appropriate data type before plotting.\n", - "14:12:10.790 INFO:\tUsing categorical units to plot a list of strings that are all parsable as floats or dates. If these strings should be plotted as numbers, cast to the appropriate data type before plotting.\n", - "14:12:11.486 INFO:\tMetrics plot saved to: /tmp/vis.png\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Plot saved to: /tmp/vis.png\n" - ] - }, - { - "data": { - "image/png": "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XV1dfPz4EQ8fPhSC6ZaWlpg7d24+l5go/9y/f194PH78+HwsCRERERERfa0YNCciIipiPn36hF27diktb9KkCSZPnpwPJSIiIiraEhIS8ODBA4X00zLly5fHpk2bYGtrmw8lIyoYevbsiZ49e+Z3MYiIiIiI6CvGoDkREVERZmxsjAoVKqBXr14YOnQo9PRYNSAiIsoL9vb2OHDgAG7fvo379+/j06dP+Pz5M8RiMaysrGBvb4/27dujZ8+eMDAwyO/iEhEREREREX3VRFKpVJrfhSAiIiIiIiIiIiIiIiIiIsoPOvldACIiIiIiIiIiIiIiIiIiovzCoDkRERERERERERERERERERVZDJoTEREREREREREREREREVGRxaA5EREREREREREREREREREVWQyaExERERERERERERERERFRkcWgORERERERERERERERERERFVkMmhMRERERERERERERERERUZHFoDkRERERERERERERERERERVZDJoTEREREREREREREREREVGRxaA5EREREREREREREREREREVWQyaExERERERERERERERERFRkcWgORERERERERERERERERERFVkMmhMRERERERERERERERERUZHFoDkRERERERERERERERERERVZDJoTEREREREREREREREREVGRxaA5EREREREREREREREREREVWQyaExERERERERERERERERFRkcWgORERERERERERERERERERFVkMmhMRERERERERERERERERUZHFoDkRERERERERERERERERERVZDJoTEREREREREREREREREVGRxaA5EREREREREREREREREREVWQyaExERERERERERERERERFRkcWgORERERERERERERERERERFVkMmhMRERERERERERERERERUZHFoDkRERERERERERERERERERVZDJoTEREREREREREREREREVGRxaA5EREREREREREREREREREVWQyaExERERERERERERERERFRkcWgORERERERERERERERERERFVkMmhMRERERERERERERERERUZHFoDkRERERERERERERERERERVZDJoTEREREREREREREREREVGRxaA5EREREREREREREREREREVWQyaExERERERERERERERERFRkcWgORERERERERERERERERERFVkMmhMRERERERERERERERERUZHFoDkRERERERERERERERERERVZDJoTEREREREREREREREREVGRxaA5EREREREREREREREREREVWQyaExERERERERERERERERFRkcWgORERERERERERERERERERFVkMmhMRERERERERERERERERUZHFoDkRERERERERERERERERERVZDJoTEREREREREREREREREVGRxaA5EREREREREREREREREREVWQyaExERERERERERERERERFRkcWgORERERERERERERERERERFVkMmhMRERERERERERERERERUZHFoDkRERERERERERERERERERVZDJoTEREREREREREREREREVGRxaA5EREREREREREREREREREVWQyaExERERERERERERERERFRkcWgORERERERERERERERERERFVkMmhMRERERERERERERERERUZGll98FoNz37NkziMVi6OjowNDQML+LQ0REREVMUlISJBIJ9PX1UadOnfwuDmmIdUgiIiLKT6xDFj6sPxIREVF+ymn9kUHzIkAsFkMqlSI1NRXx8fH5XRwiIiIqosRicX4XgbKAdUgiIiIqCFiHLDxYfyQiIqKCILv1RwbNiwAdHR2kpqZCJBLB2Ng4v4tDRERERUxCQgKkUil0dDgzUGHCOiQRERHlJ9YhCx/WH4mIiCg/5bT+yKB5EWBoaIj4+HgYGxujVq1a+V0cIiIiKmJevHiB+Ph4pmgsZFiHJCIiovzEOmThw/ojERER5aec1h/ZVZOIiIiIiIiIiIiIiIiIiIosBs2JiIiIiIiIiIiIiIiIiKjIYtCciIiIiIiIiIiIiIiIiIiKLAbNiYiIiIiIiIiIiIiIiIioyNLL7wIQERERERUmQ4cOxf3799Wub9OmDbZs2aK0PCEhAZs3b8a5c+cQHBwMCwsLtGjRAhMnToSdnZ3a43348AEbNmzA7du3ERUVhdKlS6NLly4YO3YsjI2NtXJORERERERERERERRmD5kRERERE2VC9enWYmZkpLa9atarSsvj4eAwePBjPnz+HoaEhqlWrhqCgIBw7dgxXr17F/v37UaVKFaX9/P39MXDgQERHR8PS0hLVqlWDv78/Nm/eDHd3d+zdu5eBcyIiIiIiIiIiohxi0JyIiIiIKBt+/fVXfPPNNxptu3btWjx//hyVKlXC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yoR9O4P8nCyNaXqGxjLWqxBCCCGEEEKIahcQEICenh6zZs0qseysWbPYvn07AQEBVd8wIYQQQghR4yRoLoQQosrk52ShzMnWnFZDbRFCCCGEEEII8XhLTk7GwsICKyurEstaW1tjaWlJcnJyNbRMCCGEEELUNAmaC1GNVswaQ05WpvqzobEJs1f9VoMtEkIIIYQQQgghhHg8WFtbk5SUREpKSomB8+TkZFJTU7G1ta2m1gkhhBBCiJqkV9MNKI2LFy+yYcMGNm/eTHh4eE03R4hyy8nKJCc769+fQgF0IYQQQlQeuX4UQgghhBAP8/DwID8/n6+++qrEsqtXryY/Px8PD49qaJkQQgghhKhptaKn+dmzZ/nss89o3rw5y5Yt05g3f/58du7cqf6sp6fHu+++y6uvvlrdzRRCCCGEELWEXD8KIYQQQoiyGjduHMeOHWPz5s3cunWLsWPH4uHhQb169QC4d+8ely9fZsuWLZw+fRqFQsH48eNruNVCCCGEEKI61Iqg+f79+wkODmbcuHEa0/fu3cuOHTsAcHV1JTMzk9jYWL744gvat29Pp06daqK54oFFpxaSnffvWMVG+kZ81HNxDbZICCGEEI8LuX4UQgghhBBl1adPH15++WV++eUXTp48ycmTJ3WWUyqVALz88sv07t27OpsohBBCCCFqSK1Iz37x4kUA+vbtqzH9r7/+AuDpp59m//79HDt2jGeffRalUsmWLVuqu5niIdl52eTk//tTOIAuhBBCCFGV5PpRCCGEEEKUx/vvv8/KlSt54oknUCqVOn9at27NqlWreP/992u6uUIIIYQQoprUip7mMTExmJmZUb9+fY3p58+fR6FQMGvWLAAUCgXvvPMOu3btws/PrwZaKoQQQgghagO5fhRCCCGEEOU1bNgwhg0bRlxcHNevXyc5ORkAa2trWrRooXWNKYQQQgghHn21ImiempqKqampxrTIyEiSk5Np2rQpDRo0UE+vX78+5ubm3L17t7qbKYQQQgghagm5fhRCCCGEEBVVv359CZALIYQQQgiglqRnt7Gx4f79+yQlJamnnT17FoB27dpplVcqlRgbG1dX84QQQgghRC0j149CCCGEEEIIIYQQQojKUiuC5m3btgXgu+++A+D+/fts2rQJhUJBly5dNMrGxcWRnp4ub4EKIYQQQjzG5PpRCCGEEEII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AAAAAAAAqLAomgMAAAAAAAAAAAAAKiyK5gAAAAAAAAAAAACACouiOQAAAAAAAAAAAACgwqJoDgA3sXews3YKAAAAAAAAAAAAKCUUzQEAAAAAAAAAAAAAFZa9tRMAAFv2/tj+Sk9NMW87ODlr/KKvrZgRAAAAAAAAAAAAihNFcwAoQHpqitLTUq2dBgAAAAAAAAAAAEoIRXMAAAAAAAAAAFCuZWRk6IsvvtDhw4cVEBCgIUOGyN6eEgkAIBu/EQAAAAAAAAAAQLkWGBiooKAgSdK+fftkMBg0bNgwK2cFALAVFM1RoBMfj1ZW+t9TU9u5eOieEe9bMSNYy6czv1N6WoZ528XNSUOmPGrFjAAAAAAAAACgcEJDQ3NtUzQHAOSgaI4CZaWnypSe9ve2PWs7l3Uzd0xXWubfX1NHO0fNaD/rlnHpaRnKSM+02AYAAAAAAACAsiA1NbXAbQBAxUbRHKhg0jLTlJ6VduuBwP9n777DorjaNoDfSwcRFcWGxoJk7C32Ekvs0TeW2HvEkhg1auxiiiaaqFGjJrFXNPYS0URjSwQLatTYRQURrBSl77J7vj/4drJLExTY3dn7d11esrszw3mYss+cM/MMERERERERERERERGRFbAxdQOIiIiIiIiIiIiIiIiIiIhMhYPmRERERERERERERERERERktThoTkREREREREREREREREREVouD5kREREREREREREREREREZLU4aE5ERERERERERERERERERFaLg+ZERERERERERERERERERGS1OGhORERERERERERERERERERWy87UDSAiIiIiIiIiIiIioteXkpKCjRs34saNG6hSpQoGDRoEOzt2/xMREWUXvzWJiIiIiIiIiIiIiCzYpk2b4OfnBwC4ePEiVCoVhg4dauJWERERWQ4OmhMRERERERERERERWbAzZ86ke23qQXNzuPv9/IwZ0KnVAICkp0+NPkt6+hTnJk2CjYMD6n3zTb62i4iIzA8HzYmIiIiIiIiIiIiILIDKziHD95OTk7N8bQrmcPe7Tq2WB82FEEafCSHkz4iIiGxM3QAiIiIiIiIiIiIiIlKWjO5+JyIiMle805yIiOg13P1pDHSa/67atrF3hNcnS03YIiIiIiIiIiKyJl8FzIJam3qn9LOEZ0afPUt4hhl/TUUB+wKY3tjXFM0zy7vfiYiIMsNB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1lsAgEePHmX4IPqIiAgAqXX2S5cuna9tIyIiIiLzxBySiIiIiHKC+SMRERHR6+GgeT6pXLkybGxskJCQgLt376b7/MqVKwCASpUqyWWUiIiIiMi6MYckIiIiopxg/khERET0ejhonk/c3d1Rq1YtAMCBAwfSfa5/r1WrVvnaLiIiIiIyX8whiYiIiCgnmD8SERERvR4OmuejYcOGAQDWr1+P8+fPy++vWbMGly5dgpOTE/r372+q5hERERGRGWIOSUREREQ5wfyRiIiIKOdUIqOH21CemTFjBnbu3AmVSgVvb2/Ex8cjPDwcNjY2mDt3Lrp27WrqJhIRERGRmWEOSUREREQ5wfyRiIiIKGc4aG4C27dvx6+//op79+7B3t4eNWvWxIgRI9CwYUNTN42IiIiIzBRzSCIiIiLKCeaPRERERNnHQXMiIiIiIiIiIiIiIiIiIrJafKY5ERERERERERERERERERFZLQ6aExERERERERERERERERGR1eKgORERERERERERERERERERWS0OmhMRERERERERERERERERkdXioDkREREREREREREREREREVktDpoTEREREREREREREREREZHV4qA5ERERERERERERERERERFZLQ6aExERERERERERERERERGR1eKgORERERERERERERERERERWS0OmhMRERERERERERERERERkdXioDkREREREREREREREREREVktDpoTEREREREREREREREREZHV4qA5ERERkYXRaDSmbgIRERERWQDmjURERESUE9acP3LQnKyKTqczdROIiIjeiL+/P3bt2oW4uDhTN4WIrBjzaiIi88e8kYiIiIhywtrzRw6ak0wIYeom5ImoqCgsWrQIMTExsLHhJm8pUlJSALBD1pIkJCQgNjbW1M2gHDLcx5T6PaAkO3bswMSJE7F3717ub2SWrO04Ym3xMq+2DszDlY95u3Vg3khkvqzxPNzaYra2eAHrjNkaWeM5gjXFzPyRg+YEIDY2FmFhYVCpVIr7QouMjMSHH36INWvW4PTp06ZuTp5Q4kH7+fPn6NatG86ePauoDlklriu9yMhItGvXDosWLUJSUpKpm5MvLP14qdFooNPpEB4eLneOq1QqE7cqb1j6utLz9/eHr68vSpYsiSFDhqBUqVKmbhKRTK1WQ61WKzKfzIiS8+fMWENenRkl53BpKTUPz4o1rV/AOvP2rCj1GM68kSydUvdNazoP17O2mK0tXsA6Y86IUo9baenXsZ41xG0tMTN/TGVn6gaQaUVFRWHAgAFwcnLCggULULFiRQghFPHFFhUVhb59+yIiIgIDBgxA27ZtTd2kXBMZGYmzZ8+iU6dOsLGxgU6nU0ynln6bDAkJwd9//42GDRuaukm54vnz51i6dCn69esHSZJM3ZxcFRUVhX79+uH58+fQarVwcHAwdZNyVWJiIkJDQxEcHIyyZcvC2dkZb7/9tnyctMRjZkxMDBYuXIg7d+7g6tWrqF69OsqVK4fRo0ejWLFicHFxMXUT34hGo0FycjKePn2K0qVLw8bGBg4ODha5rvQeP36MlStXAgCmT5+Odu3ayZ89fPgQbm5ucHNzM1XzyMpFRkZixowZaNKkCXr37g1HR0eL3t9eRcn5c2aUnFdnRsn5dmaUmodnRck5ekaUnrdnRYk5fWaYN5IlsaZ9U+nn4RmxtpitLV7AOmPWU2LfU2ZiY2OxY8cO3L17F48ePUL9+vXh7e2NNm3aKC5WPWuLmfnjfzhobsViY2Ph4+ODe/fuwdHREXPmzMHMmTMV0fEXFRWF3r17IywsDIMGDcLkyZNhZ2cHjUYDe3t7AJabeEdFRaF79+548uQJEhIS8OGHHyqmI89wvQ0ZMgTjxo0zdZNyRUxMDPr164cHDx4gKioKn332Gby8vEzdrFyRdp19/vnnitkegdT4pk2bhitXriA6OhrOzs5QqVT46KOP0KZNG1SuXFm+y9BSjifR0dEYOHAggoODUaxYMTg4OODWrVu4dOkSrly5gv/973/o3LkzypYta+qmvpaYmBj88MMPuHbtGkJCQlCuXDlUqVIFH374IerUqWPq5r02jUaDiIgIVK1aFe+99568ze3fvx/z58/H5MmT0bFjR9jZMbWj/PXixQv069cPoaGhePLkCZydnfHBBx8ourNAqflzZpScV2dGyfl2ZpSah2dFyTl6RpSet2dFiTl9Vpg3kqWwpn1T6efhGbG2mK0tXsA6Y9ZTat9TRqKiouDj44Pr16/L7wUGBgIAunfvjhEjRqBMmTKKyiusMWbmj/9R9pkRZUqtVmP16tW4fv06PD09Ubp0aQQGBmLOnDm4d++eRZeajIqKQp8+feSOvUmTJsk7s75jD7DMMjHx8fGYMGECnjx5AgCYOXMmdu3aBQByh4elStsh+/nnn8Pe3h5qtdrUTXsjiYmJ+Prrr/HgwQM4ODjgyJEjWLx4Me7evWvqpr2xjNaZnZ0dUlJSMux4s7Tt88WLFxg8eDBOnjwJT09PtG7dGtWrV0dCQgKWLVuGb775Bjt27AAAizlmJiUlYcaMGQgODkavXr3wxx9/YO/evVi9ejVq1KiB+/fvY9WqVZg/fz7u3Llj6ubmmP6Ebfv27Xjy5AmcnJwQHByMXbt2oV+/fti4cSMePnxo6ma+FpVKBQ8PDwQHB+PcuXNQqVTw9/fH5MmTER0dDRcXF6tIXMm8JCUlYcGCBQgNDUWhQoVw48YNrFu3Dvv27VNkqXYl58+ZUXJenRkl59uZUWoenhUl5+gZUXrenhUl5vSvwryRLIE17ZtKPw/PiLXFbG3xAtYZs56S+57SiouLw5gxY3D9+nV07twZ27dvx5w5czBgwACoVCrs3r0bvr6++PPPPxXz2B9rjBlg/mhEkFU6f/68aNeunWjWrJnw9/cXv/32m+jcubOQJEkMHTpU3L17VwghhE6nM3FLcyYqKkq0bdtWSJIkvvrqK6HVauXPNBqNuHz5svj555/F1KlTxfjx48Xhw4fFnTt3TNji7NNoNGL9+vWicuXKokOHDmLatGlCkiQhSZLYuXOnPJ1hzJbi+fPnol27dkKSJDF37lyRnJycbprY2Fjx4sULo/fMffvU6XRi7969on79+qJDhw5i69atol69ekKSJPHpp5+K4OBgUzfxtT179ky0b99eSJIkvv/+e5GUlGT0uVarFZcvXxaXLl2SjyeWJCUlRXz11VdCkiQxY8YModFohBCp++Hu3btFu3btROXKlUXz5s3F8uXL5W3R3LfJiIgI0bJlS9GlSxf5Pf0xIzo6Wnz33Xfi3XffFZIkiUGDBombN2+aqqk5lpSUJD7++GMhSZLw9fUVkZGR4smTJ+LYsWNiwoQJQpIkUaVKFeHr6yuuXLli6ua+lh9++EFIkiRq1KghfvrpJyFJkmjYsKHYv3+/qZtGVurIkSOiadOm4r333hO7du0Sn3zyiZAkSXTq1Els375d/j4392Njdik1f86MkvPqzCg5386MUvPwrCg5R8+I0vP2rCg1p88O5o1kzqxt31TyeXhmrC1ma4tXCOuMWQjr6HsydPz4cVG5cmXh4+OT7rPAwEDRtGlTIUmS6NKli9i2bZuIj483QStzlzXGrMf8MZWVXBpAaYWHhyM0NBTt27fHu+++CwcHB+h0OqxatUq+Y8YSS00ePHgQDx48gKOjIypWrCi3W61WY/ny5Th06BAePHggT3/8+HF4e3tjxIgRaNOmjamanS1hYWHYuXMn3NzcMHToUHTs2BGFChXCunXrMGPGDABAjx49LK7EXlxcHAYOHIjQ0FC0a9cOU6ZMkdebRqPBn3/+iYCAAJw9exZJSUlo0qQJatSoIV/dZc5evnyJI0eO4OXLlxg0aBD69OmDChUqYPTo0Thy5AgAWGQZSI1Gg/HjxyMkJARVq1bFqFGj4OjoCCB1X9uxYwdOnDiBv//+GwBQuHBhVK1aFePHj8fbb78tT2vObG1tcePGDRQqVAjjxo2DnZ0dtFot7Ozs0K1bN5QtWxYbN27E8ePHsXr1aiQnJ2P8+PFmXzouJCQEjx49kktjJSUlwcnJCRqNBoULF8bo0aNRokQJbN68GWfPnsWCBQswdepUi9hGnz17hitXrkCSJHzxxRewtbUFABQvXhytWrVC2bJlsWrVKuzYsUO+q6Fu3bombnX26I/p48ePR0JCAjZt2oQlS5bA1dUV3333HVq0aGE0HVF+SE5OxtmzZ/H8+XMMHToU3bt3R40aNaDVanHixAmsX78eABRVql2p+XNmlJxXZ0ap+XZmlJyHZ0WpOXpGrCFvz4pSc/qsMG8kS2Bt+6aSz8MzY20xW1u8gHXGDCi77ykjwcHBEEKgSZMmAICEhAS4uLhAq9WicePGWLFiBb788kv8+++/WL9+Pezt7dGpUyeLziGtMWbmj8aUHyFlqFGjRpg4cSI++ugjuLq6wsHBAW3btsWIESPg7e2NwMBAzJ492+JKTfbv3x8TJkyASqXCggULsG3bNrx48QJLly7FihUrkJycjJ49e2Lq1Kl4//33Ubx4cVy5cgXTpk3DoUOHTN38LDk6OuLOnTvw9PRE8+bNUbBgQYwePRpDhw4FAMyYMcMiS0eGh4ejXLlycHR0RHR0NM6fPw8g9dmYCxcuxJdffomdO3fi0aNHePbsGQ4cOIA5c+bA19dXLhlprtuni4sLHB0dUa9ePXTo0AEA0LBhQyxbtgyurq4WXQayf//+eOutt3D9+nUsWbIEKSkpAIDvv/8ec+fOxfnz51GqVCmUKVMGOp0OgYGBmDZtGg4fPoyEhAQTt/7V4uLiEBYWBhsbGzk2lUoFjUYDAKhXrx7GjRuHzp07IyUlBb/++itWr14tT2euihQpAjs7Ozx58gTJycnyCY29vT2ioqKQnJyMwYMHY/jw4fD09ERgYCDWr1+Pp0+fmrrpr/Ts2TM8f/4cpUqVgq2tLTQaDYQQ8jr77LPPMHnyZLi6uuLw4cPYunUrbt68aeJWZ4/htte4cWP5/aSkJHh6egJI7RS3hsSVzIe9vT0qVqyI9u3bo0uXLgAAb29vTJgwAS1btsTdu3exfv16RZVqV2r+nBkl59WZUWq+nRkl5+FZUXKOnhGl5+1ZUWpOnxXmjWQJrG3fVPJ5eGasLWZrixewzpgBZfc9ZUSf64eEhABIzaNTUlJga2uL5ORkVKtWDT/88APq1auHe/fuYdOmTfI5haWyxpiZP6aRz3e2kxkxLL2nL5+SmJgo9u/fL5eaHDJkSLpSk+ZaDiklJUX+ecWKFaJmzZqiVq1aYvr06eKdd94R7dq1E6GhoUbtP3nypBg9erSQJEm0a9dOXLx40RRNfyV9m+/cuZOujbGxsWLevHkWXTry6tWrYsKECaJy5cpi4MCB4sSJE3IJkPfee09s3rxZnD9/Xuzfv1/MnTtXVK5cWUiSJKZNm2bqpmdKv86SkpLEvXv3hBCp60O/Ts6cOSPeeecdiy0DqVarxdGjR0XLli2FJEli1qxZYvXq1aJy5cqiXbt2IiAgQDx+/Fg8efJE/PPPP/IxpUOHDiIgIEAIYb7bp1arFYmJiXKbt2/fbvR5WFiYOHLkiHj8+LF4+vSpmDRpkqhWrZpo3769OHLkiIlanT2GcS1dulSo1WohhBDh4eGiTZs2olWrVmLPnj0iOTlZ/PLLL6JBgwaiSZMmYu/evUII4+Osubly5YqQJEk0a9ZM3uf07X327JlcVnbnzp2iTp06onLlymLevHkiNjbWZG3OqT179ghJkkSjRo3EgAED5JJJ//zzjxDCfPcpUi61Wi2ePHkihEj93tN/9926dUuMHDlSkaXalZY/Z0bJeXVmlJ5vZ0aJeXhWlJ6jZ0TJeXtWlJzTZwfzRjJX1rhvKvk8PDPWFrO1xSuEdcYshHX0PRkKCgoSkiSJjh07iqtXr8rv379/XwwePFjMnj1bREZGilu3bokuXboISZLEqFGjLPr83xpj1mP+mIqD5lbCcIN+1Y6bVcefYUdhdHR0nrQ1p9I+X1FvxYoVcoeH4ReZYaeIEEKcPXtW9OnTR1SvXl2sXr1aCGGeB7e0ByXDNr6qIy9tImIu8Rm249q1a2L8+PGiSpUqokuXLuKdd94Rbdq0EY8fPzaaJzExUezZs0fusFu6dGl+NzvbMvsi0cedVaecPtlMO485SdsBV61aNdGqVSvx6NGjdNM+ffpUdO/eXUiSJHr37m0RX7Jr166V23v9+nUhhBAvXryQn/s5aNAgodVqxdWrV8VHH30kJEkSEyZMyPBZoOZAfxzYsGGDqFmzpujWrZvcEXr8+HH5+DF79mwhRGqskyZNEpIkie7du5ttXIYGDx4satSoIdavX290wta8eXPRuXNn+di4cuVKOd5Dhw4JIcxzHzMUEhIi6tevL6pVqyb++OMPIYQQM2fOlBPYy5cvCyGsJ4El03vVPpPVwHna44m5brdKzp8zYy15dWaUmG9nRul5eFaUnqNnROl5e1aUltNnB/NGsgTWsm9aw3l4WtYWs7XFK4R1xmxIyX1PaUVERIjevXsLSZLEkiVL5PbPnj1bSJIkatWqJU6cOCGEEOLy5cuiZs2aQpIksXDhQlM2+41YY8xCMH80ZCX305O+3BGQWm4hq1KCTk5ORqUmT58+jdmzZ+POnTtwcHAAAEycOBGzZs3CkydP8rztr2IYm52dnVwqcMSIEfDx8UHp0qXh4+ODChUqQAgBGxsb2NjYyKU26tevj4YNG0Kj0WDv3r1yCVFTM1xH+nYbMmyjq6trpqUjhRDy81VOnz6NuLg4k8enj02lUkGr1QIAqlatimHDhqFTp064ffs2bGxs8OOPP6JEiRLyNEDq9vnBBx9g4sSJsLW1xalTpxATE2OKMF4ps5Il+pKtDRs2xPLly9OVgdSXMwKAlStX4vLlyyZfZxmxt7dH8+bN4evri+LFi0Or1WLevHkoWbKk0TrTarXw8PDA4sWLUaxYMVy6dEl+XqQww5Ke+jY1bNgQVapUwdWrV3Ho0CFERUUZlWKNjo5GQkICqlWrhsGDB8PW1hb+/v44ePCgKZufKf1xoEmTJqhcuTKuX7+OLVu2ICQkBM2bN0e3bt3g5eWFzp07AwDc3NwwefJklClTBteuXcO+ffsAmOc6A1KPK3Xr1oVarcbWrVvlkqqhoaF4+vQp7ty5g8uXLwMAhg8fjgEDBgAAvvnmGzx48MAs9zFDBQsWxKRJk7Bo0SK0a9cOADB79mz06NEDarUaAwYMwJUrVxRRLpjMn8jGsyTffvvtDEu1x8fHy/nktGnTcPz4cbMt8aXk/DkzSs2rM6PkfDsz1pKHZ0XpOXpGlJq3Z0WpOX12MG8kc2Zt+6bSz8MzYm0xW1u8gHXGrKf0vqe0SpUqhW7dugEAfvrpJ+zevRsA0Lx5c5QpUwYpKSkoUaIEAKBmzZqYPXs2bGxscPz4cURERJis3W/CGmMGmD8aydchespXL1++FGvWrBHTp08XQ4cOFT/99FOOShmlvWPmo48+EiEhIeKrr76SrzB5+vRpHkaQuVfFZnjV1ubNm+WrVtPST3f9+nVRt25d0blzZ5Nf7aZWq4VWqxUPHjwwusMnO9LeAWNY5mrChAmiTp06Yvv27Sa7qi2z2AyvUPr333/FlClTxJw5c7IsXXPp0iXRoEEDIUmSuHPnTp62O69kdDfLJ598IsLDw4UQQkydOlVIkiRGjBhh8u0yK/o7V6ZNmyYePnyY4TQajUbodDoxZsyYDEuwmSv9VaG1a9cWGzZsEEKklmL68ssv5dI0er/88ouQJEmMGzdOCGHeV96dOnVKVK9eXb5aX7/N6fc5rVYr76M//PCDxdxNFhsbK7p16yYkSRJdu3YVkZGRQqPRiG+//VZ07NhRXLhwQZ42JiZGDBgwwOgKSnO/4tfwOGD48/Tp063yyk/KH3FxcSIwMFD4+fmJLVu2iJs3b4rnz5/Ln79qWzO847xjx45i3759IiUlRcyaNUtIkiQaNGggEhISzGb/U3L+nBkl59WZUXK+nRnm4dmnlBw9I0rO27Oi1Jw+K8wbyRJY276p1PPwrFhbzNYWrxDWGbPS+570DNtpeP5z+PBhIYQQjx8/Fvfv3xdC/Fd94MGDB3LJ8qCgoHxv85uyxpgNMX9MZWfqQXvKG1FRUfDx8cH169fl9wIDAwEA3bt3x4gRI1CmTBnY2dllereQ/o4ZrVaLdevWISAgAEOGDMGjR49QtGhRbNy4ER4eHvkWk15OYgOA/v37Z7osnU4HW1tbvHz5EvHx8ShevLh8N5ApxMTEYOHChbhz5w6uXr2K6tWro1y5chg9ejSKFSsGFxeXLOfX3wEDAOvWrYOvry/s7e3x77//wt/fH05OTmjUqJFJrmrLbmzVq1fHgAED4OzsDFdX10yX9/bbb6N48eJ48eIFkpKS8iuMXJX2bpbRo0fj6NGj8hWbhw8flq/GNOV2+Sr29vZo0qQJateuDXd39wynUalUUKlUcHZ2BgAkJCTkZxNzTKfTwcbGBsOHD0dISAh27dqFxYsXw97eHn369EGNGjUApN6Rp1KpYGtri5o1awIAbt26BbVaLd+JZE70x/umTZvihx9+wJgxY+Dv7w+tVosRI0ZAkiSj6QCgdOnSAFKPveZMq9XC1dUVCxcuxKhRo3Djxg2MHDkSGzZswLRp0zB69Gi4ublBp9NBpVKhYMGCqFy5MoKCgnD+/Hm0a9fOrK741W+Dhj8bHgccHByQkpICOzs7fPPNNwCAXbt2YcCAAdi8eTNq1qxptAyi1xEVFYVx48bhn3/+ke9AdnZ2Rp06ddC7d2+0b9/+lduY/o5zADhx4gRWrFiB7du34/z58yhWrBjWrVsnfzeYmpLz58woOa/OjJLz7cwwD88ZpeToGVFi3p4Vpeb0aTFvJEtjLfumnpLPwzNjbTFbW7yAdcYMKK/vKSv6ams2NjYYOXIkXr58iV27dmHMmDGYP38+unTpYjQtAJQtWxZeXl64ffs2nj59aqqmvzZripn5Y+aUFxEhLi4OY8aMwfXr19G5c2ds374dc+bMwYABA6BSqbB79274+vrizz//RFJSktwpkBEnJyd07doVI0eOhLu7Ox49eoRChQph48aN8PLyyufIch4bgEzLRWi1WrnjY+fOnQCABg0aADBNeZjo6GgMGDAAO3bswMOHD+Hg4IBbt25h37598pdvWFjYK5fj6uqKkSNHYuTIkQCAqVOnws/PD0WKFMGuXbtQtmzZvA4lnZzGVr169Uy3L/36TEpKwsuXL+Hl5YVy5crlSxx5wbBTbs2aNXB1dcXhw4dx+PBhFC5cGFu3bjXJvpZTTk5OmXa86TvRgdRyRYULF8Y777yTn83LMRsbG7lU5TfffIMOHTogISEB8+fPx9q1a+Xt1c7OTh5IqlixIpydneVBAnNMgg2P923btsXixYsBAL///jsWLlyI33//HcnJybC1tZXX2cWLFwEAVapUAWC+5bNsbW0hhECFChUwa9YslClTBv/++y8GDBiAx48fw83NDUDqulWr1bCxsUHdunUBQB4MMjXDv218fDxSUlKQmJiYaQJqZ2dntJ327NkTarUa/fv3t56SSZRnXr58iWHDhiEoKAj169fH6NGj0b59exQuXBiBgYEYN24cVq5cma0Bs7fffhszZsxAq1at8ODBA5w/fx6FChXChg0b4O3tnQ/RvJqS8+fMKDmvzoyS8+3MMA9/PUrJ0TOitLw9K0rN6QHmjWTZlLxvZkTJ5+GZsbaYrS1ewDpjBpTR95QT+ryicOHC+Pjjj9G1a1cAwKRJk+Dn5yc/qskw/4iMjISLiwveeuut/G5urlByzMwfsymP72QnEzh+/LioXLmy8PHxSfdZYGCgaNq0qZAkSXTp0kVs27ZNxMfHCyHSlwYxLNs3efJkuYRmcHBw3gaQhdeNLS3DWJcsWSIkSRKdO3cWjx8/zrO2ZyUxMVF8/PHHQpIk4evrK+Li4kRoaKg4f/68+PDDD4UkSaJOnTpizJgx4vbt21kuS18aJCEhQXzwwQcmX2+vE1tmZWoMt8mvv/5aSJIkpk+fbnFlEdNSq9Xyz8OHDzf5OstN+u1RCCHmz58vJEkSPj4+4sWLFyZsVfYZbnOff/65kCRJ1KxZU0ybNk2cOnXKaFp92Z6vv/5aLmtprgzbdvToUfnY2axZMzF27FgRFBQkLl26JObMmSOXVDbV8fF1qNVqcfz4cdGmTRv5O+HUqVMiKirKaDp9eVV92VFTrjPD3/3XX3+JoUOHip49e4revXuLkydPisjIyEznNdzPZs6cKZeP+vfff/O0zaRcOp1O/PTTT0KSJDF58mSjz86dOyd/B0uSJL777jvx5MmTTJdl+B03evRos/2OU3L+nBml5tWZUXK+nRnm4W9GyTl6Riw9b8+K0nJ65o2kFErbN19F6efhGbG2mK0tXiGsM2Y9S+x7elOPHz8Wvr6+cv4wbdo0ceDAAZGQkCDi4uLkEvx9+/YV0dHRpm5urlBKzMwfs08lhAVe0kNZWr16NRYsWIApU6Zg6NChSEhIgIuLi3wHyLVr1/Dll1/i33//RcWKFTF8+HB06tQJjo6OGS5v4sSJ8Pf3R6FChbBlyxaTXlGfW7ElJiYiOTkZ3333Hfbs2YNixYphw4YNJovt0aNH6NevHwoWLIj9+/cD+K8sRkxMDFauXAl/f388efIEDRs2xPTp0+UyN4YMS2JMnz4du3fvNvl6y43YRJoSqIsWLcKKFStQunRpbNy4EWXKlMnXmPLKtGnTsGfPHpOvs9yQdp199913WLduHYoVKwY/Pz+LuitJX4oGAObOnYtdu3YhPj4ejo6O6Nq1K9zd3XH37l388ccfKFmyJDZv3mwR26ThOrp48SL8/Pxw5swZREZGwsnJCcnJyRBCoHTp0li1apXFbY86nQ4XL17EzJkzERISghIlSqBevXro2LEjihYtCn9/f2zevBnly5fHhg0bUKJECVM3GQDw559/4tNPPwXw350Vrq6u6NmzJz788MNM14PhXZ4TJkzAwYMH4e/vb3HrjcyHPv/bt28fJElCYmKiXKpXrVZjw4YNWLhwIQBg4MCBGD16NAoXLpzp8sz9O07J+XNmlJpXZ0bJ+XZmmIfnDnM/fr0pJeXtWVFiTs+8kZRAiftmVpR+Hp4Ra4vZ2uIFrDNmPUvte3pTy5cvx88//wydTgedTgdvb2+o1WqEhobCw8MDGzZsQMWKFU3dzFyllJiZP74aB80VaNWqVVi4cCF69+6Nr776CsB/SWhycjIcHR0RFhaGadOm4fz586hatSomTpyIpk2bplvWlStX0KtXLzg4OGDv3r0m3/FzI7aYmBhs3rwZv/76K54/f47KlStj0aJFqFChgqnCwunTpzF06FA0aNAAGzduRFJSEpycnKDRaGBvb4/4+Hjs3LkTmzdvRlhYGJo3b46pU6dmelCaNWsWtm/fjsKFC8PPz8+kB6/ciu3Zs2d49OgRVq9ejcOHD6NEiRJYu3atYg7My5cvx9KlSxXVGRcWFoYzZ87g0KFDCAwMRNmyZfHLL79YZGyGicHevXtx8uRJHDp0SP7czs4OXl5e+OGHHywqPsMTm+fPnyM0NBRbtmxBdHQ0dDodatWqhV69esHT09PELX19UVFRmDFjBi5evIgXL14Yfebp6YmVK1eadJ0ZroMHDx5g+PDhePLkCXx8fFCjRg38/vvvOHjwIIQQ+OCDDzB48GBUqlQpw2UZbqdPnz5F8eLF8y0OUg6dToekpCQMHToUly9fxtKlS9G2bVsAxp2aAPDrr7/iyy+/BACMHDkS48ePB5B+AEY/nZubm9mWNFZy/pwZpebVmVFyvp0Z5uFvTok5ekaUlLdnxdJzeuaNpFSWvm/mlDWch6dlbTFbW7yAdcZsyNz7nvLC0aNHERQUhL179yIpKQlFixZFtWrVMHHiRMVcdJmWJcbM/PE15Mft7JS/goKC5HInV69eld+/f/++GDx4sJg9e7aIjIwUt27dEl26dBGSJIlRo0bJpfW0Wq3R8o4cOSLu3LmTrzFk5k1jE0KIly9fivXr14sBAwaIGTNmiPDwcFOEYuTGjRuiWrVqol27diIpKUkI8V85wMjISLk8xrZt20Tr1q1F1apVxcyZMzMshxoVFSV69OghmjVrZhalA3MjNo1GI9avXy+X/ujTp48ICQnJ/2DyiFarFVu2bBFDhgwxm30tN5w5c0ZUrVpVLu344MEDUzfpjRgeGxMSEsSpU6fE5s2bxaJFi8TRo0ezLE9MpmFYPvfkyZNi3rx54qOPPhIjR44US5cuFQ8fPjRxC/+j0+lEaGiokCRJbN682eizdevWiVatWonq1auLGTNmZHmcMCyZRPQmvvvuOyFJkliyZIkQ4r/ymSEhIWLx4sUiKChICCGMvp/9/PwyXFZwcLDw9fV9ZclrU1Jy/pwZpebVmVFyvp0Z5uFvRqk5ekaUlrdnRQk5PfNGUiIl7JtEZJ0sqe8pN6Q9942MjBShoaEiMjJSJCYmmqhVeUsJMTN/zD4OmitQRESE6N27t9zRqX9ewezZs4UkSaJWrVrixIkTQgghLl++LGrWrCkkSRILFy40Wo45Pl8jt2JLSEgQYWFhIi4uLt9jyEhiYqLo3LmzkCRJLF26VO7MCg8PF23atBGtWrUSe/bsEcnJyeKXX34RDRo0EE2aNBF79+4VQqQ/WEVERIhHjx7lexwZedPY9F9Kjx8/FgsXLhSLFi1SzLNvDKWkpIjY2FhTNyPXBQYGiu3bt2f5XBRLYm7HxbRJ25vS6XRGMZpbvK8j7d8oKSnJ7OLatWuXaNeunVi0aJHo3bu3/L5+gEMIITZv3pztBJYoN+zZs0dIkiTq1q0rrl+/LoQQIj4+XvTq1UtIkiQ++OAD+RnXy5Ytk6c9c+ZMhsszfDawOVJy/pwZpebVmVFyvp0Z5uFvTqk5ekaUlrdnxZKO1WkxbyQls+R9800o8Tz8Vcw95txuj7nHKwT7l96UJfQ95QVriDEtS4yZ+WPO2Jj6Tnd6MzqdLt3PpUqVQrdu3QAAP/30E3bv3g0AaN68OcqUKYOUlBT5+Rk1a9bE7NmzYWNjg+PHjyMiIkJenmFZTVPIi9hE6oUicHZ2RpkyZVCgQIF8jio9rVYLJycn9OzZE46Ojjh27BiCgoIAALdv30ZYWBgiIiJw9epVODg4oG/fvmjRogUiIyOxceNGqNVquSwGkFpyo1SpUihZsqSpQpLlRmz6Z0aWKFEC48aNwyeffGLy57+IXH6qhRACtra2cHV1zdXlvm5bcnM5jRs3Ro8ePeDu7p4ryzU1Ux8XgdRSuP7+/gAg7x+5RaVSGcVoDvG+Kf3fSL9NOjo6mlVcarUav/32G0JDQ7Fz506Eh4cjNDQUQgg4OjrK33/9+/fHsGHD4OHhgX379mH9+vUIDg42cetJKQxzLv2+0rVrV7Ru3Rrx8fGYOHEiHjx4ABcXF1SsWBEqlQqhoaGIjo4GAPTs2RPt2rVDUlKS/D2f9vvE3t4+n6J5NSXnz5mxlrw6M0rOtzOj1Dw8K0rO0TPCvP31meux+lWYN5LSWdq+mVvHYSWeh7+KOcYcHx8vHytVKlWu5hXmGC/A/qXcZO59T3nFGmJMy9JiZv6Ycxw0t1AajQY6nQ7h4eFISUkBkHpw1m/kvXv3xtChQwEAM2bMwJEjR9CiRQv4+fnhwIEDqFy5MrRaLQCgTp06qFSpEu7cuWPU6WcqeRlb2i9sc6DvgGvSpAkqV66M69evY8uWLQgJCUHz5s3RrVs3eHl5oXPnzgAANzc3TJ48GWXKlMG1a9ewb98+AP99KZtTfLkVm37d29rawsHBIV9jSEpKwr179xAYGIhz585Bo9FAo9EYtetNmWqd5WVshjHlduKdU7ndgWpKkZGR6NmzJ77//ntcunTJ1M2xKOZ0bDTk4OCABQsWoEWLFnj+/DlevHiBsLAwqFQq6HQ6o++/tAnsxo0bcfPmTRNHQEqgz7eA1H1FrVYDAMaMGYOqVavi3r17+OqrrxAZGYlPPvkE8+bNw6ZNm+Tn0Xl4eKBRo0bQarXYs2cP4uLizHKfU3L+nBlry6szo+R8OzNKyMOzouQcPSPWkrdnRUk5/eti3kjmyBr2TbVajYcPH+LYsWP4888/8fjxYyQmJsr7nhKp1Wo8efIEp06dQkBAAOLi4uRcUonr/Pnz5+jWrRt+/PFH3LhxA0DuD5ybG/Yv5Q1zyh8zotRjVlasMWZDzB9zzs7UDaCci4mJwcKFC3Hnzh1cvXoV1atXR7ly5TB69GgUK1YMLi4uAIARI0bg5cuX2LVrF8aMGYP58+ejS5cu8nL0B/GyZcvCy8sLt2/fxtOnT00Sk56SY3uVSpUqYezYsRg1ahT+/PNPODo6YuLEiZg7dy7i4uLg6uoKnU4HnU6HYsWK4f3338eKFSvw5MkTAOb9pfymsZmq8yY6OhpTp07F5cuXERMTAwCoXr066tSpAx8fH5QoUQJCiFf+7fVfQGq12mw6G5UcGwC8ePECt2/fRv369eUTHXPeR7IjMjIS/fr1Q1hYGAYOHIgaNWq81nLMdZ3lJnNZ3wcPHkTJkiVRt27dLKcrWrQo5s6di+nTp+PEiRP4/PPPsXnzZlSqVMkogbWxsUH//v0BABs3bsT27dvh4OCAyZMnK3ZdUt6JjY3Fjh07cPfuXTx69Aj169eHt7c32rRpI29P5cuXR79+/bBixQoEBARgxowZmDVrFj744AMA/5182tjYoGPHjli7di1iY2Pl73ZzYo05pjXG/CpKzrczY6l5eFaUnsemZW3xGlJiTp8Z5o1kSaxp34yJicHs2bNx+fJlPHz4EADg5eWFBg0ayDmV0o7BMTEx+Oabb3Dx4kWEh4cDSK069L///Q/dunXLdnUhS4k5JSUF3377LR48eICIiAg4OztjyJAhqFKlSo62b0uJF2D/Um4x92NfYmIinj59iuDgYBQrVgxly5Y1qiiU021bT6vVGlXhMifWFjPzx7xhfmfAlKXo6GgMGDAAO3bswMOHD+Hg4IBbt25h3759GDlyJDZs2ICwsDAAQJEiRTBq1Ch07doVADBp0iT4+fnJJ9qGO35kZCRcXFzw1ltv5XdIMiXH9ir6KxebNm2KH374AQDg7++P77//HtevX4ezs7M8nf7AXrp0aQBAVFSUCVqcfZYaW0xMDAYNGoSTJ0/C09MTnTp1wltvvYXg4GBs2rQJ/fr1w5UrV7J9YhQUFAQfHx/5hMOUlBwbkLrd9OrVCxMmTMCpU6cAvN4VwvrBIH2nsSlFRkaiT58+CA0NxaBBgzBlyhTY2toa3R2aHea6zuLi4vDbb79h3rx5WLp0Kfz8/BAVFZXjO6j00yUlJeVovrxw9uxZTJgwAZ9++imuXLnyyund3d3x7bffolWrVoiJicFnn32Gu3fvGiWuhld+9unTB2+//Tb69u1rVYkr5Y6oqCgMHjwY33//PXbt2oXAwEAsWbIEn376KaZPn4779+9DrVbD2dkZbdu2Rb9+/VC6dGmcOHECkydPRkhICID0JfdSUlJQpEgRsyvTbY05pjXG/CqWmpO+CSXGrPQ8Ni1ri9eQEnP6zDBvJEtiTftmVFQU+vfvL5evrl27NooUKYK7d+9i165dWLx4MWJjYxV1DNbH/Ntvv8HR0RHvvfcePD09ceXKFaxfvx5//fUXgFefa1tSzPrHsQCQ767csGFDju44t6R4ld6/lBUl9j1lJjo6Gp999hkGDBiA0aNHo0+fPujevTt++eUXOdfITrUMrVYLGxsbXLp0CStXrgSQWoGKMZse88c8lHuPR6e8lpiYKD7++GMhSZLw9fUVcXFxIjQ0VJw/f158+OGHQpIkUadOHTFmzBhx69YtIYQQOp1ORERECF9fXyFJkpAkSUybNk0cOHBAJCQkiLi4OPHDDz8ISZJE3759RXR0NGMzEZ1OJ/986NAhOaaPPvpIHDhwQCQlJRlNP3nyZCFJkti+fXu6+c2NpcWm0WjE119/LSRJEjNnzpTff/Lkidi+fbu8TTZq1Ej88ccf6dqf1vXr10WtWrWEJEmia9eu4vHjx3kdQqaUHJsQQsTGxorevXsLSZJEtWrVxPvvvy/+/vtv+fPsbkv66c6fPy8kSRJLly7Nk/ZmR2RkpGjbtq2QJEnMmTNHqNVqozZmJKvPzG2dRUZGyuvM8F/v3r3F8uXLRVRUlBBCiJSUlCyXo9VqhRBCXLx4UXTq1ElcvXpVCGG6Y6NGoxFjxowRkiSJL774ItvtiIyMFCNGjBCSJInOnTuL4OBgIcR/8en/F0KImJiY3G84KV5sbKzo16+fkCRJTJw4UVy+fFns2LFDzJ49W1SuXFlIkiQGDBggDh48KOLj44UQQkRFRYlNmzaJDh06CEmSRNOmTcWJEyfEs2fP5OUuXLhQSJIkJkyYIJKTk00VXjrWmGNaY8zZZWk5aW5QUsxKz2PTsrZ4DSkxp88K80ayFNa0bxrGOn36dDm/vX//vvjss89EzZo1RevWrcXZs2eFEK+O3RKOwS9fvhR9+vQRkiSJGTNmyH0PYWFhYty4cUKSJPHZZ59le3mWELPeoUOHRN26dcWUKVNEs2bNhCRJYurUqeL69evyNEpYx0rvX8qKUvueMhITEyPef/99IUmS6Natmxg+fLjo1q2bfOzu3Lmz8Pf3l6c3zBcycvbsWVG9enUhSZJYsmRJXjf/tVhjzMwf8w4HzS1IRESEaNmypejSpYv8nn4jjo6OFt9995149913hSRJYtCgQeLGjRvydDqdTixbtkxUq1ZNVKlSRd4p2rVrJyRJEs2aNRN3797N95j0lBxbThge3I4ePSqaNm0qxzB27FgRFBQkLl26JObMmSMkSRIdO3Y064TEkCXFlpycLPr27SsaNGggnj59Kr8nROoX0oMHD8TQoUOFJEmiQYMGYv/+/UKj0WS6vMOHD4tWrVrJCdnDhw/zJY6MKDk2tVotli1bJiRJEm3atJEHhTp16vRaJ/JhYWHydipJkli3bl0etTxzkZGR4r333pNPaAwTl5SUFBEWFiZ27NghNm7cKNatWyeCg4NFbGysECLzBNCc1tnLly9Fjx49hCRJYtSoUWLz5s1i1apVolWrVvIxfciQIfKx4FUnL5cvX5bjatq0qdF3RX7S7zMajUasX79eJCYmCiGEfBL2KpGRkWL48OHy95n+OyyjBJYop44fPy4qV64sfHx80n0WGBgoH/e6dOkifv31VxEXFyeESN1fDx8+LPr37y8kSRK1a9cWffr0Eb6+vuKjjz4SkiSJ5s2bi9DQ0PwOKUvWmGNaY8w5YUk5aW5RSsxKzmMzYm3x6ikxp88K80ayFNa0b2o0GvliwU8//VQ+9ur/v3fvnujSpYuQJEnMnj07W8s092NwYmKi+PLLL+WY9Rdi6WMODg6WB6M0Go3Res5snZt7zIbOnj0rateuLY4dOyYOHTokGjZsKCRJElOmTDEaODeUNm5zj1fp/UtZUWrfU0Y0Go346quv0l10KYQQ69atE3379pXbvnXrVvmzrPKF6dOny/M0adIk2zlKfrHWmPX/M3/MfRw0tyCBgYFCkiQxcOBAIYSQdwb9VWFxcXFi/fr1ok2bNkKSJOHj4yNfKaL3559/irlz54qGDRuKWrVqidatW4sxY8aIkJCQ/A0mDSXHllOGSdeFCxfEhAkTRJMmTYQkSaJWrVryXWCtWrVK9zcwd5YQm06nE2FhYaJx48aidu3a4v79+/JnhklTcnKyGDlypJAkSTRs2FAcP3483TSG/P39Ra9evUy6zpQcmxBCXL16VbRv3140adJEHDhwQAQGBsodia9zIp+cnCx8fX1FtWrVRM2aNeW78fKLTqeT7/hr1qyZCAoKMmrb8uXL5asm9f86dOggpk2bJif6mSU55rLONmzYICRJEuPGjTNaJ/fv3xc//vijfMzv2LGjCA8PF0JkffJy9+5dUaNGDSFJkqhfv75JB+/StnPXrl2iYcOG4sKFC9ma/1UJLNHrWrVqlZAkSaxdu1YIIeS7yfXb7NWrV+W7Fzt27Ch2794t52VarVbExMQIX19fucNFkiRRo0YN0atXL7McTLXGHNMaY84pS8hJc5ulx6z0PDYta4vXkNJy+uxg3kiWwJr2zWfPnon3339ftG7dWjx58kQI8d9+qo/t4MGDokqVKsLHxyfbFwqY8zH42LFjolq1aqJr167yQLlhzMHBwaJFixZi9uzZ4smTJ+LatWvi+fPn8qCqJX/vCJGaJ7dr106MHz9eCCHEli1bROPGjeWBc/3dxEII8ejRo0yXY67xWkP/UlaU3PeUkQEDBogGDRqIFy9eCCH+Ox8UIrXCx5QpU+T1vGnTJvmzrPIGfbUJc13P1hgz88e8w0FzC3Ljxg1RrVo10a5dO/mKP33nV2RkpIiMjBRCCLFt2zbRunVrUbVqVTFz5kw5wTMUGRkpQkNDRWRkpNFBxFSUHNvrMPwCf/bsmTh//ryYMGGCGDp0qBg8eLD44YcfzPbqvVexhNi0Wq0YOHCgqFq1qjh48KAQ4r+rax8+fCiOHDkiXr58KYQQ8t11jRo1kjuzDL9cDH/Wn0yYkpJj0w8SfPTRR/KVdQEBAXIcOTmR18eWnJwsvv/+e3Hv3r28bXwmwsLC5FI7PXv2FJcvXxZJSUli3rx5QpIk0bhxY9G/f3/h6+sr2rZtK+rWrSskSRL9+/eXT+QM4zS3dTZr1iwhSZL466+/hBCpx319e2NiYsShQ4dE586d5U78iIgIIUTGJy/6927fvi3atGkjbt68mU9RZMzw7+7v7y8n5++99564dOlStpZhmMA2b97cqMOc6HWtXLlSSJIkZs2aJb+nv0pZn4M9ePBAvqO8W7du4u+//xY6nc5o37t37544ceKE2LVrlzh//rzZXXmtZ405pjXG/DosISfNbZYes5Lz2IxYW7x6SszpX4V5I1kCa9o37927JyRJEkOGDBFqtTrDAYQLFy7I56n63CozlnAMfvDggWjSpInYvHmzEOK/8wN9/n/kyBFRu3Zt0aBBA9GxY0f5WOPj4yPnDobnCpYQc1ojRowQHTp0kF9v3bpVHjifOnWq/N37wQcfiP379xvNawnxKr1/KStK7nsypNVqxdOnT0XdunVF9erVxe3bt+X1pD8fFCL1wg99ZQlJ+u+xTEKkP3YbVjEyx0pU1hizHvPHvMNBcwuSmJgoH8CXLl0q7/jh4eGiTZs2olWrVmLPnj0iOTlZ/PLLL6JBgwaiSZMmYu/evUKI/3Z4c3rGhp6SYyPLotPphEajEd9++618QqgXGRkplx2aPn26ECK1xM/AgQOFJKU+4zOjkyVz2S6VHJsQqSfce/fuFZcvX5bf0+l0IjAw8LVO5F9Vjim/PHr0SHzyySdCkiTRvXt3sWTJElGjRg3Rrl07cfPmTbl08sOHD8W6devkErpjxozJ8Jmz5rTOJkyYICRJMjoxT1tWNDAwUHzwwQdCklKfkaUvT5pRHPp1ZpgYm9qePXvku78GDBggJEkSLVu2zFECqy8l9eDBgzxuLVmDoKAg+Sp6wzsm7t+/LwYPHixmz54tIiMjxa1bt+Syk6NGjUo3+GoprDHHtMaYSfmUnsemZW3xGlJqTp8dzBvJnFnbvvnXX3+JEydOZPp5YmKiaNeuXbYGzYWwjGPw48ePje6i1g883b59WzRq1EhIkiR69Oghpk2bJsaMGSM6deokDzKGhYUJIYzjtISYhfgvzs2bN4vKlSuLgIAA+TPDgfOBAwfKzzjet29fuuVYQrxK7l/KijX0PenpdDoxZMgQUbVqVfkYpo/14cOHwt/fX8THx4vIyEgxc+ZMIUmSqFu3rlytKCPmfry2xpgNMX/MfTYgi6DVauHk5ISePXvC0dERx44dQ1BQEADg9u3bCAsLQ0REBK5evQoHBwf07dsXLVq0QGRkJDZu3Ai1Wg07OzsAgEqlMmUo6Sg5ttwiUi9wMXqtFOYWm0qlgp2dHXr37g03NzcEBATgiy++AAC4urrCw8MDAHD+/HnExMSgYMGCGD9+PMqXL49bt27J265hHOayXSo5NgBwcHBAly5dULNmTQCATqeDSqVC48aN4ePjg6ZNm+Lu3buYO3cuTp06BSC1/UII6HS6dMuztbXN1/ZnpmTJkvD19cV7772Ha9eu4aeffkKRIkWwdu1aSJIEFxcXCCHg6emJHj164KOPPoKHhwcuXryIy5cvAzC/dab/e3t5eQEAzpw5AwCws7ODnZ0dQkJCMH36dOzduxeNGzfG+PHj4eXlhRs3buD7779HbGxshnHo15n+O8HUnj17hpkzZ8LGxgbffPMNNm3ahPfffx+PHj3CZ599Jq8fQ2mPge7u7li2bBmOHz+OsmXL5lfTScE8PT1Ru3Zt3Lt3D0ePHpW3uc2bN+PMmTPYuXMn/v33X7z99tuYM2cOHB0dcfz4cSxfvhwAYG9vb8rm54g15pjWGHNuMbecND9YUsxKz2PTsrZ4DSk1p38V5o1k7qxt32zevDlatGiR6ecqlQoqlQovXrxATEyM/L5WqzWaTv/aEo7BJUqUQMmSJeXXNjY2ePr0KUaOHIno6GgMHjwYO3fuxLfffoslS5Zg/vz5qFmzJiIiIrBo0SIkJSUZxWkJMQOpcQJA5cqVIYRARESE/FmfPn0wffp0uLq64ty5c9BoNPjoo4/wv//9DwCMtm1LiFeJ/UtZsZa+J0MqlQqSJEGr1WLu3LkIDw+HnZ0dXrx4gWHDhmHChAmYMGEC3N3dMWTIELRr1w7x8fHYunUrwsPDM1ymuR6v9evXmmJOi/lj3uCguYXQ76hNmjRB5cqVcf36dWzZsgUhISFo3rw5unXrBi8vL3Tu3BkA4ObmhsmTJ6NMmTK4du0a9u3bB8A8O0KUGltutkefjBu+NiUlx6ZXsWJFTJ8+Hc7Ozti2bRuWLl0KBwcHDBs2DIMGDcK8efNQuHBhAIC3tzfq1auH+Ph4HDt2DID5xJERJcemP9nR/6zfVrM6kdfpdPJ8jx8/NrtjCfDfiU2rVq3g7u6O8ePHw9PTU+6o0K+TggULonXr1vD29sbz589x/PhxAOa3zvR/7/bt28PJyQlHjhzBqlWr5M9//PFH7N69G1OnTsXNmzfRuHFjDBkyBMWKFUNgYCACAwMBIMPOF8B84vXw8MCqVauwcOFCvPfeewCAuXPnZprAarVaue2RkZHytuju7o5SpUrlfwBk8Qz3Ef3PpUqVQrdu3QAAP/30E3bv3g0gtWOwTJkySElJQYkSJQAANWvWxOzZs2FjY4Pjx48bdSBZAqXmmFmxtpitISdNyxpjNqTkPDYj1havnlJz+qwwbyRLYI37ZkZ0Oh0cHR3h5uaGlJQUaDQaAKn7pT4X27FjB8LDwy1m4CUz8fHxqF27NsaPH49p06YBADQaDVQqFd5++2306tULDg4OuHfvHlJSUkzc2ten0+lQqVIluLu74/Dhw0hMTJTXa4ECBZCcnCxPm5CQgJs3bwIw3icshdL6l7JiLX1Pevrj67Bhw1CjRg2EhIRg5cqViIqKgo2Njfz3CAkJQWxsLLy8vNC1a1eUKVMGQUFBCA4ONlqOOYqJiYG/vz8A4+8hHx8fxcacFeaPeSTX712nPHfq1Cm5HMyECRNEeHi4EOK/54dotVq5BMUPP/wgl2a0BJYaW2Jiorh7964ICAgQZ8+eFWq1Wn7eXEbPPrIkSo7tVWJjY8XcuXNF9erVRc2aNcXq1auNPtdqtXKZntOnT4uqVauK3r17i/j4eFM0N0eUHFtahqWU0paOMyy3NnbsWNG5c2dx+/ZtUzQzW8LDw8Xs2bPF3bt3s5xu165donLlymL8+PH51LLsyej5V8uXLxfVqlUTHTt2FL///rsQQoj58+eLatWqibp164qQkBAhROqzVydNmiQkSTK7uLJLf+zUaDRyebCMSiYtXLhQDB48WNy6dcsUzSQF0D978cGDB0Zl5wy/t/XPrpMkSRw+fFgIkVqSUf8MK305sgcPHshl2oOCgvIviFxmqTnmm1BazNaYk1pjzNlhTXmsENYXb2aUlNNnB/NGshTWtm8aGjVqlKhbt64IDg42en/58uVyOW+NRmMxJawzY1h+Pm3J4itXrojq1auL2rVrizt37lh8rKNGjRItW7aU4/T39xe1a9eW+yGaNm0qJEkS06ZNE1euXDFxa9+MpfcvZcUa+p6Sk5NFWFiYOHr0qDhy5Ih49OiRSEhIEEIIkZCQINavXy8aNGggGjVqJJYvXy60Wq04fvy4mDdvXrpcwtfXV34ckGFeaW6eP38u2rRpI959913xzz//GH2WnJysyJhzivlj7jC/GhKUKSEEVCoVmjZtih9++AFjxoyBv78/tFotRowYAUmSjKYDgNKlSwMAoqKiTNbu7LDk2KKjozF16lRcvnxZLslUvXp11KlTBz4+PihRooRRuzOjv/pWrVbDwcEhH1r+akqOLTtcXV0xfPhwvHz5Evv378eiRYsQHR2Nzz//HEDqFYUajQYODg4oVKgQVCoVnJyc4OjoaOKWv5qSY0tLXxJOXzpOLyAgAAsWLICDgwMOHz6MP/74AzY2NnB1dTVha7NWunRpTJo0KdP1kJKSAjs7O8THx0MIYRb724sXL3D79m3Ur1/faF3or/Zs2bIlgoKCcPr0aWzatAnFihXD+PHj0bRpUxQrVgzlypWDTqdDsWLFMGTIEPj7++PYsWMICwtDmTJlzO7K3qw4ODhAq9XCzs4O3333HQDA398f48aNwy+//ILKlStj+fLlWLlyJYDUK9qJciomJgYLFy7EnTt3cPXqVVSvXh3lypXD6NGjUaxYMbi4uAAARowYgZcvX2LXrl0YM2YM5s+fjy5dusjL0e9bZcuWhZeXF27fvo2nT5+aJKY3Yck55utSYszWmJNaY8zZZU15LGB98WZGSTl9djBvJEthbfsm8N93q0qlQnx8vFF59uXLl2Pp0qUoVKgQfH19zbJ8c065u7sDSM0d9XfO6/sebGxsYGdnh6pVq6JcuXIWdX5uSL9Oq1WrhuPHj+PGjRsICwvD9OnTkZSUhFmzZqFfv37Yvn07li1bht27d8PR0RGSJFlsfmWJ/UtZsaa+p5iYGMyePRuXL1/Gw4cPAaSWoG/QoAE++eQTeHh4oGPHjrh58yb27duHX3/9FY6Ojujbty9atmwJ4L9HR9ja2qJPnz7w9/fHixcvzLZ6QmRkJPr164ewsDAMHDgQNWrUMPrcwcEBnTt3VlTMr4P5Y+5QzhZhBfQHfABo27YtFi9eDAD4/fffsXDhQvz+++9ITk6Gra2tnMRcvHgRAFClShUA5ltqwlJji4mJwaBBg3Dy5El4enqiU6dOeOuttxAcHIxNmzahX79+uHLlSrY7s4KCguDj45Pp8zTyk5Jjy4miRYti/Pjx6NGjB2xsbLB69WpMnDgRT58+RUpKipw07t27FykpKahevbpZJVJZUXJsaRkeY/Tllpo3b447d+5gwoQJ2Lp1K9zd3fHbb7+ZfTmazE5o9EkRkNpBoR8wAUx37I+KikKvXr0wYcKEdM+106tatSoGDBiAChUq4Pz581iyZAnOnDmDxo0bw9vbWy79plarIUkSvL29kZSUhPj4eIvcHm1tbY0S2E6dOuHx48f4+OOPMWvWLLmD5cCBA/D09DR1c8nCREdHY8CAAdixYwcePnwIBwcH3Lp1C/v27cPIkSOxYcMGhIWFAQCKFCmCUaNGoWvXrgCASZMmwc/PT+70MzxxjIyMhIuLC9566638DumNWWqO+SaUFrM15qTWGHNOWVMeC1hfvJlRUk6fHcwbyVJY276pj9XZ2RnAf881XrZsmbxf+vn5wdvb22RtzAv67xXDvoe1a9ciMTER9erVS3eub0n05z5NmjQBAPz888+YNWsWkpKSMGPGDPTr1w8A0KtXL/j4+KBcuXLo37+/2Q8kv4ol9S9lxZr6nqKiotC/f3+5RHnt2rVRpEgR3L17F7t27cLixYvx4sULFC9eHGPHjkWrVq3w9OlTrFu3DitWrMCTJ0/SLdPBwQEajQb29vb5HU62REZGok+fPggNDcWgQYMwZcoU2NraGj0SQqfToWjRooqJ+U0wf8wFeXofO+UJw3IRR48elUvDNGvWTIwdO1YEBQWJS5cuiTlz5ghJkkTHjh3F48ePTdji7LOk2DQajfj666+FJEli5syZ8vtPnjwR27dvFx9++KGQJEk0atRI/PHHHyIpKSnL5V2/fl3UqlVLSJIkunbtatJ1puTYXldUVJRYtmyZqFOnjpAkSXTr1k3MnDlTbN++XUycOFFIkiRatWolHjx4YOqm5piSY0vL8Bhz5swZ0apVKyFJkmjQoEG6kmqWxLBE6+LFi4UkSaJnz57i6dOnJmtTbGys6N27t5AkSVSrVk28//774u+//5Y/1+l0RuvjwIEDonXr1kKSJNGrVy+xe/du+TPD8tLvv/++aNWqlYiKisqfQPKIYVk7fckkSZJE/fr1xZ07d0zYMrJUiYmJ4uOPPxaSJAlfX18RFxcnQkNDxfnz5+Xv7Tp16ogxY8bIJbh0Op2IiIiQS5PpSw0eOHBAJCQkiLi4OLlsd9++fUV0dLRpg3wDlpRj5hYlxGyNOak1xvwmrCmPFcL64s2MUnP6zDBvJEthbfvmnDlzRK1atcQ///wj1qxZYxX7pWHfg/48oUOHDiIiIsKErco9kZGRol27dvJxdsOGDfJnarVa/vnFixemaF6+MMf+paxYU9+TYazTp0+XS3Hfv39ffPbZZ6JmzZqidevW4syZM/I8Dx8+FOPGjRPVq1cXderUEZ988km6kvz6R7fNmTMn3d/L1CIjI0Xbtm3l9un3w6za+ODBA4uOObcwf3x9KiHM8PIgeiVhUIrv4sWL8PPzw5kzZxAZGQknJyckJydDCIHSpUtj1apV8PLyMnGLs89SYlOr1RgyZAju3r2LAwcOwMPDQy57mJKSgkePHuGLL75AYGAgChUqhJkzZ6Jjx46ZlmY6cuQI5s6di4iICADA0aNHTXa1j5JjexMajQYXLlzA119/jfDwcCQnJ8ufVapUCUuWLLGofc2QkmMzpNVq5bvqpk2bhj179qBQoULYsmWLRcen0+mg0+kwe/ZsbNu2DUWLFsWmTZtQsWJFk7RHo9Fg5cqVWLp0KcqWLYvixYvjwoUL8PLywrRp09CsWTMAqcd7IYR8VfehQ4ewYsUK3Lp1C25ubvjggw8wduxYODg4wMHBAYsWLcKKFSvQvHlzLFq0yKJL+xlui0uXLsXy5csVsS2S6Tx69Aj9+vVDwYIFsX//fgD/3WUaExODlStXwt/fH0+ePEHDhg0xbdo0VK5cGUDqvvjTTz/h559/lo8n3t7eUKvVCA0NhYeHBzZs2GCyY0pusZQcMzdZeszWmJNaY8xvylryWD1rizcjSs3pM8O8kSyFteyb+vxqwYIFWL16NerVq4fz58/Dzc0NW7ZsQaVKlUzdxDyTkpICjUaDr7/+Gnv27EGxYsWwYcMGRa3fq1evok+fPpg8eTIGDRoE4L/zKv3/SmZu/UtZsaa+p5SUFCxduhQrVqxA27ZtsXDhQjg4OMjnCffv38e4ceNw+/ZtDBgwADNnzpS31ydPnmDt2rU4cuQIIiIiUKhQIXTt2hUeHh64ceMG/P39UapUKWzatAllypQxdagyfQWBhw8fYuDAgZg2bZq8DrVaLR49eoQzZ84gMTERWq0WzZs3h4eHB9zc3BAeHo6NGzdaXMy5ifnj6+OguQUz7AR7/vw5QkNDsWXLFkRHR0On06FWrVro1auXRXaKmHtsQgiEh4ejV69eSExMxJ49e1C+fHkAxgcktVqNsWPH4sSJEyhcuDDmzZuHli1bGk1j6ODBg9iwYQO+/fZbkx28lBxbbnn+/Dlu3ryJS5cuQQiB8uXLo2HDhihevLipm/bGlBybobFjx+Lw4cMoXLgw/Pz8LHqbjIqKwq+//or9+/cjJCQElSpVwo8//mjSE5pr165h4sSJiI2NxfTp0+Hu7o5Vq1YhMDDwlScvgYGB2LNnDw4fPozk5GR4e3vLZe+uXLmCYsWKwc/PD+XKlTNZfG/K8DtO/8w7a+hgobx1+vRpDB06FA0aNMDGjRuRlJQEJycnueRYfHw8du7cic2bNyMsLAzNmzfH1KlTjY5/R48eRVBQEPbu3YukpCQULVoU1apVw8SJEy16nzNk7jlmXrDUmK0xJ7XGmHOTteSxetYWb0aUlNNnhnkjWSKl75v6/VI/CAEABQsWxJYtWxRXkt3Q8+fPsW3bNuzbtw8PHjxA1apVsXDhQlSoUMHUTct1UVFR8rPcrWGgXM8c+5eyYk19T8+fP8eQIUOQmJiIrVu3onjx4nLurz8mHTp0CBMnTkTTpk3lZ1brY3758iXOnTuHrVu3IiAgwGjZ3t7eWLx4sVkdq4UQ+OKLL7B9+3Z4eHhg0aJFqFevHoDUc6HVq1fjzz//xPXr1+V5KlSogDp16mDMmDEoVaoUoqKicPHiRYuJOTcxf3wzHDQnek06nQ5DhgzBhQsXsGDBAnTs2FG+uis8PBw3btxAw4YNUbBgQQwbNgwBAQEoUqQItm7divLlyxslXYY/x8XFmfwKNiXHRvTkyRO0aNECQGonsrkm/9kVFRWFH3/8EcePH0edOnXw+eefm/wqSf3gXdOmTbFgwQIUKVIEgYGBWLNmDQICAjI8eQH+e0bao0ePcPnyZSxatAjx8fF4/vw5SpYsiQoVKmDWrFmKOSnX35lQpEgRbNq0iYkrvZGbN2/iww8/hKenJ/bv3w9HR0d5wDwqKgoA4O7uju3bt2PFihV4/PgxunfvjjFjxqQbYImKipK/s11cXODk5GSKkIisMie1xpiJXofScvpXYd5IlsKa9s2//voLI0aMgJ2dHfbt26fYwRe9Z8+eYe7cubh27RqaNWuG4cOHo2TJkqZuVp6ypgFzwDz7l7JiTX1P9+/fR8eOHdG4cWOsXLkStra26bbNixcvol+/fihdujR27twpX/hhOIAKpB6bnz9/jkePHqF69eqoX7++WV50+fDhQ3z//fc4fPgwatasiZkzZ0KSJCxevBjr1q2Du7s7KlasiIoVK8rV1OLj41GvXj18//33KF26tLwsS4k5tzF/fD0cNFeQtAf+tAdES2ZusQkhoNVqMX/+fGzYsAFNmzbFmjVrAKQmGB9++CEiIiLQo0cPfPPNN4iNjcXo0aNx7tw51K1bF8uWLZO/uAyXaQ7rS8mx5SbDmJQWn5Jj07t79y5sbGzMKgF+E7GxsQgJCUG5cuXg5uZm6uZArVbj0KFDqFChAmrWrAkgdVs6c+YMVq9ena2TFwCIjIxEXFwc7t69C29vb7i5uaFQoUL5H1AeOXPmDIYMGYLffvtN0XckUP5ISkpCz549cefOHXz66acYOXIk7O3tERERgcGDB0Or1WLs2LHo1KkT1q1bh7Vr18LOzg6TJ0/GBx98gJSUFNjZ2Sn2uJ8Zc8sx84OlxGyNOak1xpzbrCGPNWRt8aaltJw+K8wbyZJYy76pVqvh7++PWrVqKfriAEMxMTF49uwZPD094eLiYurmUB4wt/6lrFhb39Pff/8NnU4nX5iUVlJSEj744ANoNBqjQXO9zKpSmbPHjx9j9uzZOHr0KKpVq4YWLVpg9erVKFWqFH788UeUKVMGBQoUQHh4OI4cOYKtW7ciNDQUbdu2xddff40iRYqYOgSTYv74eqznUikzlZvXLKhUKqMDvqlPmJUcm0qlgp2dHXr37g03NzcEBATgiy++AAC4urrCw8MDAHD+/HnExMSgYMGCGD9+PMqXL49bt24hKCgIgPHfyNQx6Sk5ttxkTttjblNybHpeXl6KOoEvWLAgatSoYTYnNA4ODujSpYt80qLT6aBSqdC4cWP4+PigadOmuHv3LubOnYtTp04B+G9b02q1AFKPIUWLFkW5cuXQunVrlC1b1ixPWt5Eo0aNcPHiRSau9Ma0Wi2cnJzQs2dPODo64tixY/L38e3btxEWFoaIiAhcvXoVDg4O6Nu3L1q0aIHIyEhs3LgRarVafh6yJRz3lZxjZsYaY7bGnNQaY85tlrBt5yZrizctpeX0WWHeSJbEWvZNBwcHdO3a1WoGzAGgcOHC8Pb25oC5gplb/1JWrK3vqXnz5pkOmAP/nee9ePECMTEx8vv6WPUD5ikpKXnaztxUsmRJ+Pr64r333sO1a9fw008/oUiRIli7di0kSYKLiwuEEPD09ESPHj3w0UcfwcPDA//88w+uXLkCIHW7sFbMH18PB83zUVJSEu7du4fAwECcO3cOGo0GGo0GgOXvvEqOLSsVK1bE9OnT4ezsjG3btmHp0qVwcHDAsGHDMGjQIMybNw+FCxcGkPqsjHr16iE+Ph7Hjh0DYN4dG0qOjYjynmGZKBsbG7kDP6uTF51OJyfxT548ydVBInPFzgbKDfr9pkmTJqhcuTKuX7+OLVu2ICQkBM2bN0e3bt3g5eWFzp07AwDc3NwwefJklClTBteuXcO+ffsA5O7AbG6xxhzTGmPOijXmpNYYMxG9GvNGIvPD71wi02LfUyqdTgdHR0e4ubkhJSVFPn80vLt8x44dCA8Ply+YtxT6gfNWrVrB3d0d48ePh6enp3yRhP44XLBgQbRu3Rre3t54/vw5jh8/DgBW9YiFjDB/zDnL2kMsWHR0NKZOnYrLly/LV/pUr14dderUgY+PD0qUKJGtcmr6Z6non2tnDpQcW3a0adMGN2/ehJ+fH1avXg0XFxcMGzYM7dq1A/DfVW6urq7o3Lkz9uzZg9DQUCQkJJj9QUvJsRFR/lKpVPJ3QePGjeX3AwICMHfuXEyePFm+YnbcuHG4d+8efvjhB14NSZQDlSpVwtixYzFq1Cj8+eefcHR0xMSJEzF37lz5ecY6nQ46nQ7FihXD+++/jxUrVuDJkycAzK/TzxpzTGuMOTusMSe1xpiJiIiIiN6EtfY96QeGixYtCjs7O3lgXD9g/tNPP+HHH39EgwYNsHbtWtja2prd+X9W9APna9eulSsLZDQY7uHhgc6dO+P06dN4+fJlfjeTFMK6L7PIJzExMRg0aBBOnjwJT09PdOrUCW+99RaCg4OxadMm9OvXD1euXMl251dQUBB8fHwQHh6eTxFkTsmxZZerqyuGDx+OLl26QKvVYtGiRViwYIH8uUqlkq/uKlSoEFQqFZycnODo6GiqJmebkmMjovynP3kB0l/1u2DBApw+fRpfffUV/vjjD9y9exeurq4mbjGR5dDvW02bNsUPP/wAAPD398f333+P69evw9nZWZ5On5eVLl0aQOqzks2NNeaY1hhzdlljTmqNMRMRERERvSlr7HvSVyRTqVSIj483Ks++fPly/PjjjyhUqBB8fX1hZ2dnUQPmeqVLl8akSZMyfSSGvux8fHw8hBCKuHicTIN3muexlJQULF26FHfu3EHPnj0xe/ZsAMDTp09x8uRJbN++Hf/++y9GjhyJr776Ci1atMi0o8PGxgY3btzA8OHDkZSUhE8//RS//PILSpQokZ8hyZQcW04VLVoU48ePh729Pfbs2YPVq1fj0aNHmDJlCooUKSIfpPfu3YuUlBRUr17dYr6clBwbEeW/tFf9pqSkwMbGBn///TcmTJiA6OhouLu7Y9OmTShVqpSpm0tkMQz3rbZt22Lx4sX47LPP8PvvvyM2Nhbdu3dHmzZtjHKxixcvAgCqVKkCANm6gzk/WGOOaY0x55Q15qTWGDMRERER0Zuytr4n/UUC+ovl9XeaL1u2DMuWLUOhQoXg5+eHSpUqmayNuSGzc2CtVivHHBAQAJVKhaZNmwIwn34OshwcNM9jOp0ON27cQKFChTB27FgAgFqtRvHixdGtWzc0atQIX3zxBQIDA+Hr64uZM2eiY8eOmT5b4uHDh3B3d0dERARu3LghX0FjCkqO7XV4eHjgs88+Q/HixbFmzRr4+/vj/v37qFatGmrWrImzZ8/iwIEDKF26NHr37m1Rz9NQcmxElP8MT16aN28OBwcH3L17FxEREShUqBA2bdoELy8vUzeTyOIY7lsdOnTATz/9hFmzZiEgIAC3b9/G4cOHMXDgQNjb2+PAgQPYt28fKlasiHfffVee3xxYY45pjTG/DmvMSa0xZiIiIiKiN2VNfU/6Muzu7u5wcnKCEAJr167FsmXL4ObmpogB88wYPqN+yZIlOHHiBGrWrIlGjRoBMJ9+DrIcKqG/DIVynRAC4eHh6NWrFxITE7Fnzx6UL18eQOrVL/qdWa1WY+zYsThx4gQKFy6MefPmoWXLlkbTGDp48CA2bNiAb7/91mQHdiXH9qY0Gg0uXLiAr7/+GuHh4UhOTpY/q1SpEpYsWcLYiMjqGX4PTJs2DXv27EGhQoWwZcsWHkeI3pDhldQXL16En58fzpw5g8jISDg5OSE5ORlCCJQuXRqrVq0yq33OGnNMa4z5TVljTmqNMRMRERERvQlr6XvS9wEsWLAAq1evRr169XD+/Hm4ublhy5Ytih0wB1IHzXU6HWbPno1t27ahaNGi2LRpU6Zl3IlehYPmeUyn02HIkCG4cOECFixYgI4dO0KtVsPBwQHh4eG4ceMGGjZsiIIFC2LYsGEICAhAkSJFsHXrVpQvX15+JqF+Wfqf4+LiTP68DSXHlhueP3+Omzdv4tKlSxBCoHz58mjYsCGKFy9u6qa9MSXHRkT5a+zYsTh8+DAKFy4MPz8/RZ20EJmS4cD58+fPERoaii1btiA6Oho6nQ61atVCr1694OnpaeKWpmeNOaY1xpwbrDEntcaYiYiIiIjehNL7nvTn/0uXLsXy5csBAAULFsSWLVvg7e1t4tblnaioKPz666/Yv38/QkJCUKlSJfz4448cMKc3wkHzPCSEgFarxfz587FhwwY0bdoUa9asAZC6Q3/44YeIiIhAjx498M033yA2NhajR4/GuXPnULduXSxbtgzu7u7plmkOJSWUHBsREeWPJ0+eoEWLFgBS74hkUktE1phjWmPMRERERERE+cGa+p7++usvjBgxAnZ2dti3b5/iLg5IKyoqCj/++COOHz+OOnXq4PPPP0eZMmVM3SyycHzgWR5SqVSws7ND79694ebmhoCAAHzxxRcAAFdXV3h4eAAAzp8/j5iYGBQsWBDjx49H+fLlcevWLQQFBQFI7fQyXKY5UHJsuckwPqVdn6Lk2Igof5QoUQL+/v44dOiQok9aiMyBEMIivrutMce0xphzkyVs17nNGmMmIiIiInod1tT31KhRI8ydOxf79+9X/IA5kPoM94kTJ2LZsmX4+uuvOWBOuYKD5vmgYsWKmD59OpydnbFt2zYsXboUDg4OGDZsGAYNGoR58+ahcOHCAABvb2/Uq1cP8fHxOHbsGADz7vRScmy5wTA+pcWq5NiIKP94eXmhQoUKpm4GkVnKzcEwlUplUd/d1phjWmPMucGStuvcYo0xExERERG9Lmvpe3JwcEDXrl0Vf3GAoYIFC6JGjRpwc3MzdVNIIexM3QBr0aZNG9y8eRN+fn5YvXo1XFxcMGzYMLRr1w5A6rMHVSoVXF1d0blzZ+zZswehoaFISEiAi4uLiVufNSXHRkRERJQfkpKSEBERgcePH8POzg516tSBEAIODg5Gz6i2JtaYY1pjzERERERERJQ7eFEt0ZvhoHk+cXV1xfDhw/Hy5Uvs378fixYtQnR0ND7//HMAqQczjUYDBwcHFCpUCCqVCk5OTnB0dDRxy19NybERERER5bXo6GhMnToVly9fRkxMDACgevXqqFOnDnx8fFCiRIlsPaNaP7iuVqvh4OCQDy3PW9aYY1pjzERERERERERE5sD6blkxoaJFi2L8+PHo0aMHbGxssHr1akycOBFPnz5FSkqK3Lm5d+9epKSkoHr16hZzZZCSYyMiIiLKKzExMRg0aBBOnjwJT09PdOrUCW+99RaCg4OxadMm9OvXD1euXMn2gHlQUBB8fHwQHh6eTxHkLWvMMa0xZiIiIiIiIiIiU1OJ3HxYImVLdHQ0tmzZgjVr1iAhIQFVq1ZFtWrVULNmTZw9exYHDhxA6dKlsWHDBpQtW9bUzc0RJcdGRERElJtSUlIwd+5c+Pn5oWfPnpg9ezYA4OnTpzh58iS2b9+Of//9F0WKFMFXX32FFi1aZHlH8Y0bN9C3b18kJSWhSpUq+OWXX1CiRIn8CidPWWOOaY0xExERERERERGZCgfNTUSj0eDChQv4+uuvER4ejuTkZPmzSpUqYcmSJfDy8jJhC1+fkmMjIiIiyi1qtRpDhgzB3bt3ceDAAXh4eMil1VNSUvDo0SN88cUXCAwMRKFChTBz5kx07NgRdnYZP2HpyJEjmDt3LiIiIgAAR48ehaenZ36GlKesMce0xpiJiIiIiIiIiEyBg+Ym9vz5c9y8eROXLl2CEALly5dHw4YNUbx4cVM37Y0pOTYiIiKiNyGEQHh4OHr16oXExETs2bMH5cuXBwBotVrY2toCSB1YHzt2LE6cOIHChQtj3rx5aNmypdE0hg4ePIgNGzbg22+/VexgqjXmmNYYMxERERERERFRfuKgORERERGRCeh0OgwZMgQXLlzAggUL0LFjR/lO8/DwcNy4cQMNGzZEwYIFMWzYMAQEBKBIkSLYunUrypcvLz/HXL8s/c9xcXFwdXU1ZWhEREREREREREQWxcbUDaDUO40y+lkJlBwbERER0esSQkCn06FKlSrQarXYuXMnAMDBwQFRUVEYOHAgPv30U8ybNw8AsHjxYjRo0ADR0dGYPn06oqKi5EFyALCxsZFzLWsYMLfGHNMaYyYiIiIiIiIiyi8cNDcDKpUqw5+VQMmxEREREb0ulUoFOzs79O7dG25ubggICMAXX3wBIHXQ28PDAwBw/vx5xMTEoGDBghg/fjzKly+PW7duISgoCIDx4Kk15VrWmGNaY8xERERERERERPmFg+ZERERERCZSsWJFTJ8+Hc7Ozti2bRuWLl0KBwcHDBs2DIMGDcK8efNQuHBhAIC3tzfq1auH+Ph4HDt2DAAHT4mIiIiIiIiIiHKDnakbQERERERkzdq0aYObN2/Cz88Pq1evhouLC4YNG4Z27doBSH1euUqlgqurKzp37ow9e/YgNDQUCQkJcHFxMXHriYiIiIiIiIiILB/vNCciIiIiMiFXV1cMHz4cXbp0gVarxaJFi7BgwQL5c5VKBY1GAwAoVKgQVCoVnJyc4OjoaKomExERERERERERKQrvNLcC//77LzQaDWxsbNi5SkRERPkuOTkZOp0O9vb2qFGjhqmbY5aKFi2K8ePHw97eHnv27MHq1avx6NEjTJkyBUWKFIGDgwMAYO/evUhJSUH16tXzvDQ7c0giIiIyJeaQRERERJSfOGhuBTQaDYQQ0Gq1SEhIMHVziIiIyErp75amjHl4eOCzzz5D8eLFsWbNGvj7++P+/fuoVq0aatasibNnz+LAgQMoXbo0evfuDRubvC0axRySiIiIzAFzSCIiIiLKDyohhDB1IyhvXbp0CVqtFiqVCs7OzqZuDhEREVmZxMRECCFga2uL2rVrm7o5Zk+j0eDChQv4+uuvER4ejuTkZPmzSpUqYcmSJfDy8srzdjCHJCIiIlNiDklERERE+Yl3mlsBR0dHJCQkwNnZGVWqVDF1c4iIiMjK3LhxAwkJCSzxnU329vZo1KgRNm7ciJs3b+LSpUsQQqB8+fJo2LAhihcvni/tYA5JREREpsQckoiIiIjyEwfNiYiIiIjMULFixdCsWTM0a9bM1E0hIiIiIiIiIiJStLx9ECIREREREb02wycp8alKREREREREREREeYOD5kREREREZkqlUmX4MxEREREREREREeUeDpoTEREREREREREREREREZHV4qA5ERERERERERERERERERFZLQ6aExERERERERERERERERGR1eKgORERERERERERERERERERWS0OmhMRERERERERERERERERkdXioDkREREREREREREREREREVktDpoTEREREREREREREREREZHV4qA5ERERERERERERERERERFZLTtTNyC3XLlyBQcPHkRQUBBCQkKQnJyMIkWKoF69ehgyZAhq1aqVq7/v4cOHWLp0KQICAvDixQuUKlUKHTt2xKhRo+Ds7JzpfOfOncOKFSvw77//QqPRoGLFiujTpw969uyZq+0jIiIiIiIiIiIiIiIiIqJXU8Sd5k+ePEHPnj2xbt06XLt2DcWKFYO3tzfi4uJw8OBB9OnTB5s3b8613xccHIxu3bph79690Gg08Pb2xuPHj/HLL7+gf//+SExMzHC+/fv3Y/DgwTh16hRcXV1RpkwZXL16FTNnzsSsWbNyrX1ERERERERERERERERERJQ9ihg0F0KgePHimDZtGgIDA/HHH39gz549CAwMRM+ePaHT6fDNN9/g+vXrufK7Jk2ahJcvX6J9+/b466+/sHv3bhw+fBjly5fHtWvXsHjx4nTzPX36FL6+vtDpdJgyZQqOHTuG3377DZs2bYKTkxO2bduGo0ePvnH7iIiIiIiIiIiIiIiIiIgo+xQxaO7u7o4//vgDQ4YMgbu7u/y+s7MzZs2ahdKlS0On02HXrl1v/LtOnz6N69evw8XFBXPmzIGjoyMAoGTJkpgxYwYA4Ndff0V8fLzRfH5+fkhKSsI777yDjz76SH6/QYMGGDhwIABgzZo1b9w+IiIi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IiIiIqIPknvS3MDAAGlpaUhKShLWeXp6AgDatWtXpL1UKoWGhoa8wyAiIiIiIiIiIiIiIiIiIvoguSfN27RpAwDYsWMHACAtLQ2HDx+GSCSCg4ODTNv4+Hikp6cXKeVORERERERERERERERERERUGeSeNHdycoJUKsVff/0Fe3t7dOnSBY8fP4aenh4++eQTmbbu7u4AgBYtWsg7DCIiIiIiIiIiIiIiIiIiog+Se9K8e/fumDdvHsRiMdLS0pCdnQ1dXV2sXbsWurq6Mm3/+ecfAEDnzp3lHQYREREREREREREREREREdEHqSrioDNnzsTIkSPh6+sLTU1NtGvXDgYGBjJtJBIJevXqhZ49e6JPnz6KCIOIiIiIarDw8HB4eXnB19cX8fHxSEhIQFZWFgwMDGBkZIQmTZrAwcEBtra20NbWVna4REREREREREREVEUpJGkOAObm5ujfv3+J29XU1PD5558r6vREREREVAO9evUKp0+fxsmTJxEbGyusl0qlRdreuHEDu3fvhoqKCnr37o1x48bB0dGxMsMlIiIiIiIiIiKiakBhSXMiIiIiInmJj4/H77//jn///Re5ublCktzU1BStW7eGkZER9PX1oaGhgeTkZCQlJSEqKgpPnjxBTk4Orl69imvXrqF+/fr45ptvMGjQICVfEREREREREREREVUVTJoTERERUZX2xx9/YP/+/cjIyICqqip69OiBwYMHw8HBAbVr137vvtnZ2QgMDMSNGzdw8eJFREZGYuHChfjrr7+wYsUKtGjRopKugoiIiIiIiIiIiKoqJs2JiIiIqErbvn07DA0NMWvWLIwZMwaGhoal3lddXR02NjawsbHBokWL4OHhgT179sDNzQ3Xrl0rV9Lcz88Pzs7O8PLyQkREBLKysmBoaAh7e3tMmTIF7dq1K7LPh87z3//+F9OmTSt2W3R0NLZs2QI3NzckJyejTp06GDhwIGbOnAktLa0yx09ERERERERERESymDQnIiIioiptwYIF+Oyzz6CtrV3hY3Xq1AmdOnWCr68vkpKSyrz/y5cvMXbsWACASCRCgwYNoK2tjYiICDg7O+Py5ctYtmwZPvvss2L3t7W1LXZ9SSPmw8LCMGHCBKSkpMDAwADNmjVDWFgYdu7cibt37+LIkSNMnBMREREREREREVUQk+ZEREREVKXNmDFD7sds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5+Dq6ornz5/j7du37+2ZIRKJcP36dUWEUmqTJk0Syr3cuHED9erVK7bdnDlzMHbsWHTp0kUoxZ6Xl4fz588LPZOmTZtWZG5NJycnHDhwAA8fPsT+/fsxdepUAICnp6eQ1J82bZpCro2IiIioJtLR0UH37t1l/gAmUiaJRKLQpPm7/Z2lUqlCz0dERERERNVfQTK6OL/++muRdZ06dUKnTp3KdI6y/G3+oeO/L96S6OjoYOLEiZg4ceJ729WrVw+LFy8usr648tG1a9cWkqZE9HGQe9L81atXmDJlCsLDwwGUru6/SCSSy7k7duwoPC6YkN7b21tm/fbt24ude6a0CnpZaWpqokGDBtDQ0MDz58+RlJQEABgyZIjQK6kwc3NzrFixAkuWLMEvv/yCQ4cOQUdHB6GhoZBKpRg7diz69u1b7riIiIiIiIiIiIiIiIiIiKjs5J4037hxI54+fQqRSARHR0fY2dnBxMQEKioq8j5VEQWJ68JycnJk1heU+yiv+fPnw9XVFWFhYXjx4gXS09NRq1YtdO/eHWPHjsUnn3xS4r4jRoxA7dq1sWfPHvj5+SEhIQGtW7fG+PHjMW7cuArFRUREREREREREREREREREZSf3pPnt27chEonwww8/VHrpiidPnpR739LOe16aEh/vU57SJkREREREREREREREREREpBhyT5qnp6dDRUUFY8eOlfehiYiIiIiIiIiIiIiISInMzMyKnQeciKg6E8v7gBYWFtDU1ISampq8D01ERERERERERERERERERCRXck+aDxkyBG/fvoWPj4+8D01ERERERERERERERERERCRXck+aT58+HQ4ODli0aBE8PT3lfXgiIiIiIiIiIiIiIiIiIiK5kfuc5suXL0edOnXg5+eHyZMno0GDBmjYsCEMDQ1L3EckEmHNmjXyDoWIiIiIiIiIiIiIiIiIiOi95J40P3v2LEQiEaRSKQAgIiICERERxbYtaMekORERERERERERERERERERKYPck+YjRoyASCSS92GJiIiIiIiIiIiIqAoqGEAFAGlpaZBIJEqMhkojNTVV5jHvWfXA+1b98J5VT7xv1U9WVpbwuPD3krKQe9L8l19+kfchiYiIiIiIiIiIiKiKKpxM+Pzzz5UYCZXH5MmTlR0ClQPvW/XDe1Y98b5VP+Xt5CD3pDkRERERERERERER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wWAoNDdOoqKi1LNnT0VGRurtt9/WY489VmhuYly7dk1PPPGEzp8/L0naunWrunfvbtX4IiMj9frrryskJERXrlyRwWCQh4eHBg0apLZt28rHx0dSwd9MjoqK0qxZsxQYGKhz585Jkjw8PPTaa6+pc+fOVrvxHxERoWHDhun06dNq1aqVxo4dWyh+76Vb53L69OkKCgrS33//LUkqX768HnvsMT3wwANq1qyZJOucy7feekvBwcHmc+nu7q6hQ4eqR48eqlmzZoHFcruIiAg98cQTunr1qh588EHVr1/fKnH8V0REhMaPH69Dhw6Zy/z8/NSiRQu98MILVlsF5dq1a5owYYKaNm2qp556SuXKlbNKHEBWrl69qoULF2rw4MGqU6eOtcOxuoiICA0ePFhXr15VSkqK1a4XC5u4uDidO3dOp0+fVtWqVeXk5KTatWub/y4WluvZghAVFaX3339foaGhCg4OVsOGDXXPPfeYv8hcWL4QWFA4HhlFRUXpgw8+0LFjx/Tnn3/qnnvuUb169dS/f381bdrU2uEVqIiICD399NMKCQkxl+3du1eS9Nhjj2n06NGqUqWKbG1tS8znSHh4uJYuXSpJmjJlih566CFz3YULF+Tm5iY3NzdrhQcAAAAUGQYTD+cscW7cuKGnn35aR44cUb9+/fS///1P9vb2+vPPP7VgwQJt375d5cuX1zvvvKOWLVsWWFwREREaNGiQ/vrrLw0dOlSvvvqq7OzsrP4P3fj4eE2ZMkWbN29Wv3799MYbb8jOzk7x8fE6cOCA3nzzTdnb22v9+vVycHAo8PgiIiLUv39/ubu7a8KECercubOkwnGjcdGiRVq0aJEkycbGRjNnziwUifPbZ5j37NlTv/zyi5KSkvThhx+qR48eVonp+vXrGjJkiE6fPq369eurfPnyunr1qkJCQmRra6saNWroueee08MPPyyp4M7v7YnpGjVqqHLlyoqIiNDx48dVunRpzZ8/X61bty7w37ebN29q/Pjx8vf316BBgzRlypRMkxDWeB9ERkbqySef1JkzZ1SrVi15enrqxo0bOnz4sGxsbFS9enU999xz6tWrV4HGePuXpapWrapSpUopKSlJZ86cUalSpTRo0CA999xzBb6M4+3vx+HDh2vSpEmytbVN92Upa5zHqKgoDRkyxPxlqccee0xHjx6Vv7+/Tp8+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O0kqTEJkKYNGCFlCkrIbkX2bzewz957fH9O9zTAjyyxm5v18PHrknvs9537Od84995zz+S4GBQsWpHz58vmi57nFYmHq1KkcO3aMTp06MWrUKHtDgu+//57Zs2ezZ88eTp06xeXLl+nTp889NfVQZgsPD+fQoUPcd999jB49GgA3NzdKly7NZ599xpw5cwgODiYkJITIyEhGjBiRp6c8UH3c6PLly+zfv5/AwED79ThAkSJFePjhhylVqhSfffYZS5cutTdIsU0DlBcdP34cwzBo0KABALGxsbi7u2OxWKhfvz6ffvopo0aN4o8//mDevHk4OTnRrl27HBkRLjuVLFmSVq1a8emnn/Lyyy/Tp08fpk6dire3N2+99RYtWrTI6RBFRERERHIFJc3zmWvXrnH8+HHq16+Pj48PVqvVfuNtexDl7++P1WolNDSU8PBwfH19szQmk8nESy+9REREBCEhIYwfP563336bwMBApkyZwpdffomvry8PPfQQ5cuXZ+fOnVy6dInly5fz999/M3HiRIoWLZpl8ZUoUQIvLy/i4uI4f/68/YGvo6Mjf/zxB2vXriU+Pp5169bZe5t/8cUXvP7663Ts2DHL5twLDQ0lODgYLy8v3n77bRwdHZkyZYq9tzTkbOLcZDJRunRpSpcuzdq1a5k/fz5xcXEsWrQoTeLcZDJl29DUXbp04cyZM/To0YNhw4bZH163bNmSxx57jFWrVrFs2TICAgJwdXXNlrisVitXr17l4MGDJCYmcvHiRTw8PHB1dSUpKQknJycefPBBSpQogZubG4sWLeL999/HxcWFzp07Z1mMv/32G8uWLaNixYqMHz/e3sDGNn/5pUuXKFCgAAEBAfz111/ExcXZG0kULFgwS2KClKSM1Wq1zxOeuoHNpEmT+Oqrr3BwcMDNzY2zZ8+yZMkSlixZwttvv53lDQ0ATpw4QUxMDLVq1cJkMpGYmIiLi4v9M5966im2b99OcHAwAB988AEeHh48+eSTWRIPpBz769evp2jRovaEucVisTf+cXR05Mknn+SXX365oV6z0uzZszl16hRubm60bNnS/jDT1lhq/fr1HDp0yF5+1qxZdO7cmQ4dOlClSpUsje3EiROcOXOGmjVr4uTkZG/4YEuclyhRgkKFCrFmzRquXr2Kh4cH5cqVo0GDBll2bCUnJ7N//37Onz9P586dad26NfXq1cNisbBixQolziXHRUVFsX79eqKioujRowfPPPMM5cqVo2/fvvkucZ6UlMSgQYM4efIklStX5pVXXklzjlu6dCk//fQTW7duBcDb25vKlSszaNAg7rvvvjyd3HFwcODQoUN4eXkxYMAAHB0dsVgsODo68sQTT1CqVCkWLFjApk2bmDt3LgkJCQwaNCjPJs5PnjzJ+fPn7UON2xonJiUl4e3tTd++ffH39+err77il19+YeLEibz55pt59nuk+rjR5cuXuXLlClWrVsXBwYGkpCR7QxMnJycGDhyIj48PM2bMICQkBGdnZ9zd3e1THOQ1tgbiJ0+eBMDd3d1+PZuQkECVKlWYNGkSw4cPZ8+ePQQFBVGkSBEaNmyYg1FnLVtj10GDBhEbG0tQUBBTp07Fw8ODjz76iKZNm6YpJyIiIiIiGVPSPJ8pV64cn332GVarNU3v0NQqV65MmTJlsnW48ZIlSzJixAgsFgsbNmzgvffeo2nTpixcuJAyZcowbdo0SpYsSYECBTh79izr169n0aJF7Nmzhw8++IDRo0fj7e2dJbH5+fnh6+vLX3/9xfvvv0/fvn0pUKAAly9f5o033iA+Pp727dvTrl07YmNjWbNmDZs2bWLy5MmYTCa6du2aJXFduHCB8PBwqlWrRuXKlSlcuDAAU6dOvScS57bPq1KlCsuXLychIYF3330Xq9XKkiVLGDlyJFarlaeeegqTycSBAwdwcXGhQoUKmR5LYmIiAwYM4MyZMzz77LP2hLnFYsFkMmE2m2nXrh0//vgjv/76K7Gxsbi5uWVLnZnNZvz8/KhevTq7du3i3LlzVKxY0f4g7OzZs/z+++80a9aMfv36kZyczNKlS/nwww8pXLgwzZo1y5K4/P39efDBB2ndujWurq5YLBYcHBzs///777+Jj49n7dq17Nixg7/++gsvLy8aNWpE586dqVevXpbEZTKZcHBwoESJEkDK3OYAa9asYcGCBZQuXZoxY8bg7+/P4cOH+fnnn/nmm294//33SU5OplevXlneqxvgyJEjREdH2+cHN5lM9gd69erV47fffuPBBx/k+++/Z+LEiRQrVoz69etnSUzXrl0jJiaGMmXK4OnpmaaxlK3hSKVKlXB1deXEiRNcu3YtSxs+2Lz99ttERESwevVqevXqxeLFi6lYsSITJ05kwYIF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IoLnnnuOb775hg8//JBp06bx3nvvUbt2bU6cOMGGDRswDCPL6u+JJ57gwQcf5I8//mDJkiXs3buXihUrUrJkyTSfWaBAAZ5//nl69OgBpPTGDQ8Pz/R4bOemIkWKUKtWLa5cucKCBQvYunUrMTExODg4YDab+fPPPwkKCqJUqVKUKlWK1q1b4+fnx7p16zh79myW9KJzc3OjefPm9p451zObzXh5eeHn5wf821sesu784OTkZP99mTBhAh06dCAmJoZp06bh4eHB3Llzue+++3BycrKfr/r06UP37t0xm82sW7eOY8eOZUlstv1/7bXXqF27NmfOnKFLly688MILDBkyhMjISF544QUmT55Mo0aNePrppxk2bBivvPIKcONoH3cqJibG/v02m832ekh9jnRwcMBqteLr60v//v154okniI6O5uuvv2bx4sVcvXoVSOlddP2xldXnNslfTCaT/RqyXLlyAPbfMMMwqFu3LjNnzsTDw4P169czZcoUQkND7etfP7pRXjg+nZycaNy4MSNHjqRYsWIsWbKEyZMnU6xYMebPn0+DBg3w9/enSJEi1KxZky+++IIqVarw119/MW3atDzZG9JsNuPq6mqfkuTbb7+1T8MRFRVF7969ee211xg2bBiFChWiZ8+e1K1bl5MnT7J27VoSExNzMvxMZ5v2pnPnzri4uLBx40Z2794NpIx0cvr0ac6dO8eff/6Js7MzXbt2pWnTply9epUFCxaQmJiY5no2t1N9pM9qtVKrVi0SExNZtGiR/dx56tQpLl26xLFjx/j9998B+N///sezzz4LwNixY/n777/z3AgNxYoV44knngBSpu5Zvnw5AI0bN6ZkyZIkJyfj7+8PQPXq1RkzZgxms5lNmzZx7ty5HIs7q506dYoPPvgAR0dH3nvvPYKCgujcuTOJiYn06NGD/fv3Yzabs/zeX0REREQkt8tbT2LkrtkezhUqVAhHR0f7cHC2BxCzZs1i5MiRDB8+PE1iJTPZEuft2rWzD7+b3kPDwoUL06FDB0wmE1FRUVkSS2plypRhwIABjB8/nu7du+Pr64uXlxeQMvy3rb6SkpIwm824ubkBEBkZmWkx2B4i2x5+2P4+kPI3sr3/X4lz298zPDw8U26eb/Zwu2zZslSpUsU+nHFCQgIA7777Lg0bNsQwDOLj4+nQoQPdunX7z+3dDVu9Xf9AzXZ8lS9fnhIlSvDHH3/w888/p1knMyQmJnLmzBk2btzIjz/+yIULF+xDO3t4eNChQwe8vLwICQlh8eLFFChQgGHDhvH888/z0Ucf2YfMbt68OQ0aNCA2NpYFCxbc9d8vvbji4uLSPTZ8fHyoXbs2r776Km+99ZZ9fYAGDRrQokULkpOTCQ4OJiws7K7qL6O4IOXv0rlzZ9zc3Fi8eLH9HJCUlHTD8ePu7k7r1q0pVKgQ+/fvJyIi4o5jullsMTExeHt7069fPypVqsSBAwcYP34848ePZ/369Sxbtow+ffpw+fJl2rdvj5+fHw0bNqROnTrExMTw448/AneXqE5MTOTixYv8/PPPbNu2jejoaJKSknB2dr7p38JqteLo6EhcXJx9yPvk5GT7d2P16tX2B9eZFVfqeCZOnGgfznnw4MGUKlXK3ljKlhg2m8288MILNGvWjJiYGJYsWWKPPTPjsh0/1apVY+rUqfTp04cnn3ySrl27UrRoUWrWrMmwYcOAfxN9Hh4ePPXUU1SrVg2z2XzXx9iVK1d44oknmDZtmj2hlFEiwPY9zShxfvHiRftwnAMGDGD69OlA5p7bRCD9azX4t8FeRonz1EPGzpkzh99//z3PHJ+pE+dFihTBYrHw4YcfUrRo0TQNGC0WC4ULF2bKlCn4+fmxb98++3D2eaEBgY1tX+rWrUulSpX4888/Wbt2rb2hoO18Hh4eTmxsLFWqVKFnz544ODgQHBzMmjVrcjL8TGc7rzdo0ID777+fgwcP8vXXX3Py5EkaN27ME088QUBAAB06dADA09OTYcOGUbJkSQ4cOMCqVauAvHOMqD7SZ7v+qVy5MidPnmT48OGEhYVRu3ZtevbsSfny5enYsaO9fL9+/ahdu3aaxtt5pU5s+/H000/z/PPPA/DWW2+xfv16+9Ruq1ev5v7777efYx944AEqVKjAsWPH8nTSvGDBggwdOpTJkyfTunVrAMaMGcNTTz1FYmIizz77rBLnIiIiIiK3QHOaSxq2xITJZCImJibNg/+ZM2cyffp0vLy8GDlyZJqEbWYrXrw4Q4cOtc8rez3b3MAxMTH2eWazg+0hbnx8PIcPH6Zo0aIAODs722/MbQ9+4+Pj8ff3p2bNmnf1mZGRkRw9epTatWv/59zkqd9v2LAhkLbXo8VioVWrVgD079+fkJAQNm3aRLFixbI0rgoVKvDdd98RERFhn1dt48aN7N27115mzZo1NGjQgA4dOtz13/N2YkutfPnyPPnkk4wfP5558+ZRt25dypYte1ex2ERERDBmzBh+//13zpw5A0BAQAB16tTh1VdfpXDhwjzyyCMcPnyYVatWsXjxYlxcXOjatat93nLbMebg4MAzzzxDcHAwkZGRd9UT7WZx9e3bFz8/P3v9WSwWnJ2dmTlzpv3Bpm2ZYRiYzWZatGjB3LlziYuLIzo6+o7nVb6V+mrcuDHt2rXju+++Iz4+nitXrnDkyBGqVat2Qw+9Bx54gDJlyrB3717OnDlj7wmZ2bG98sorlC9fnokTJzJ27Fh+//13lixZYk/wAvaedDb169dnzZo1XLt2Dcg48XQrcY0dO5a9e/faR0qoXr06jz32GE888QQFChTIcF0nJyd7D2ZbIwjbOX769Ol88skntGzZkmrVquHq6polcU2ZMoXx48dTunRpIG092HpaOzg40KpVK0JCQuwNt7Kivjp27IiHhwe+vr4MGDAASEmQ//bbb/Z4ExMT05yrihcvjru7O4mJiXf1UDY5OZkPPviAv//+m3PnzuHm5kavXr3so4qkdz67PnEOKaMqLFy4ECcnJ7p06cLkyZP54YcfCAkJoUePHnh6euaZxKTc+65PnPft25f169djtVp56623KF68OMOHD2fFihU0bdqU6dOnZ9u1XVazJc5HjRrFjz/+aL8OSt0QxsHBgeTkZEqWLEmtWrUICQmxNwbLS99T275UrlyZdu3a8fHHHxMUFISfnx89evRg0qRJLF++nMcff9w+F3OTJk3o378/kydP5qeffqJjx455rhd+hQoV6N+/P6+88go//vgjLi4uDB48mHHjxhEdHY2HhwdWqxWr1Yqfnx/t27fn008/5eLFi0DeOkZA9XE9i8WCh4cHH3/8Ma+88gqHDh3i5ZdfZv78+QwfPpy+ffvi6elpb2xYsGBB7r//fnbv3s2ePXto3bp1nqkT26gmZrOZl19+maioKL799lv69evHhAkTePTRR9OUBShVqhQBAQEcPXqUS5cu5VToWc7X15fHH3/c/ttpu04dO3YskDKyx7PPPstXX31F9erV89x5VEREREQksyhpLmnYWm/beknbkiYzZsxgxowZeHl5sXDhQipUqJDlsWSUMLdYLPa4tm3bliZBfKvJ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M4c6derQsWNHNm/ezPjx4wkNDeXRRx/lnXfeoWDBghluP7fWzYcffsi8efMoW7Ysn3/+OSVKlCAyMpKnn36akydP0qxZM2bPnk1oaChTp04lJCSEpk2b8s477+TKkQZux8qVK3nzzTfx9vamYsWK7N69m2LFijFlyhR7Y9ybCQsL47XXXmPv3r2sX7+eUqVKZUPUIiIiIiJ5l5qhiuRyAQEB90zyN7V7NS6492JzdnamY8eO91SSFRTXnbhXY7M9iLvXEsCKSyRnmUwmHB0defrpp/H09GTbtm28++67QMocuoULFwZSppyIiIigYMGCDBo0iLJly3LkyBF2794NkKZnZG5M6NioPtLn7OzMo48+ak+YW61WTCbTLfU4T2/47dycMAdo3LhxhglzSNl3k8lEZGQkERER9uXXD71te53bj5GiRYsycuRIWrRowYEDB5g1axY+Pj588cUXBAYG4u7ujmEYlChRgqeeeooXXniBwoULs3fvXnuP2rzynbH18m3Tpg2urq6sX7+ezz77zP7+tGnTWL58OW+++SaHDx+mfv369OrVCz8/P7Zv38727dsBMpzLO7fVje0Y7927N9WqVbM3GggLC8NsNtvr6+TJk1y7do2AgAA6duxIyZIl2b17N8ePHwe4pWkOcqPLly/z9ttvYzabGTt2LEFBQbRv357z588zcODAdHucX18Xvr6+zJgxg02bNilhLiIiIiKSCZQ0FxG5B9yrD8EU1+27l2MTEUlP+fLlGTFiBG5ubixZsoTp06fj7OxM79696dGjBx9++KG9N2jFihV56KGHiImJYePGjUDeO++pPm6Uesjf1ENq3yxxnnpKiQsXLuTZxFdqVqsVFxcXPD09SU5Ots/bnLp3/dKlSzl79myubzyQmi1x/vDDD+Pr68ugQYMoUaKEvYGF7TtRsGBBmjdvTsWKFbly5QqbNm0Ccv935voh5gMCAnjppZdwdHRkxYoV/PDDDwAUL14cR0dHChQogJubG87Ozjz88MM0aNCAq1ev2svlxiG2ExMTuXjxIj///DPbtm0jOjraXi8FCxakQ4cOeHl5ERISwuLFiylQoADDhg3j+eef56OPPrL3sG/evDkNGjQgNjaWBQsWZNiAIC8oXLgwn332GR9//DEtWrQAYNy4cRkmzi0Wi/27cvXqVXv9+vr6UqxYsezfARERERGR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aCjnzp3Dy8uLoKAgAgICcjrMbGW1Wu29zSdPnkxwcDDlypXjmWeesSfSRURERERE7hW6SxERERERERERuUupk8QrV660J8y//vrrfJcwh5T6SEhIYPTo0axYsQI/Pz9mzJiRb3rbi4iIiIhI7mIyDMPI6SBERERERERERPKC/v37ExISgre3NwsXLsyXCfMrV66wZMkSVq1axd9//03lypX5+OOPKVeuXE6HJiIiIiIiki71NBcRERERERERyQQXL14kJCQEgK+//pry5cvncEQ5wzAMQkNDMZlMdO/enf/9738ULVo0p8MSERERERHJkHqai4iIiIiIiIhkktDQUMxmc77vVR0REcHly5cpUaIE7u7uOR2OiIiIiIjITSlpLiIiIiIiIiIiIiIiIiIi+ZY5pwMQERERERERERERERERERHJKUqai4iIiIiIiIiIiIiIiIhIvqWkuYiIiIiIiIiIiIiIiIiI5FtKmouIiIiIiIiIiIiIiIiISL6lpLmIiIiIiIiIiIiIiIiIiORbSpqLiIiIiIiIiIiIiIiIiEi+paS5iIiIiIiIiIiIiIiIiIjkW0qai4iIiIiIiIiIiIiIiIhIvqWkuYiIiIiIiIiIiIiIiIiI5FuOOR2AiIiIiIiIiIiIiKT13HPPsWvXrgzfb9asGZ9++ukNy+Pi4pg9ezZr167l/PnzeHl50ahRI/r160eJEiWyMmQRERGRXEtJcxEREREREREREZF71H333YeHh8cNyytUqHDDstjYWLp3787BgwdxcXGhYsWKnD17lhUrVrBx40YWLVpEQEBAdoQtIiIikqsoaS4iIiIiIiIiIiJyj3r77bepW7fuLZWdMmUKBw8epFy5csyfPx9/f38SEhIYMmQIISEhDB06lOXLl2dxxCIiIiK5j+Y0FxEREREREREREcnloqOjWbJkCQDvvPMO/v7+ALi4uDBmzBjc3Nw4cOAAO3bsyMkwRURERO5J6mmeD/zxxx8kJSVhNptxcXHJ6XBEREQkn0lISMBqteLk5ES1atVyOhy5RbqGFBERkZyka8jbt2PHDuLj4/Hz86N+/fpp3vP29qZx48aEhISwcePGG97PDLp+FBERkZx0t9ePSprnA0lJSRiGgcViITY2NqfDERERkXwqKSkpp0OQ26BrSBEREbkX6BoSvvnmG+bNm0dcXBz+/v40atSI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RERERERERERERETuAZUqVWLDhg1YLBYSEhJyOpx7gsViYf/+/QBUr14dBweHHI5IsouLi0u2/b2VNBcRERERERERERERERG5hzg4OODu7p7TYdwTLBYLrq6uALi7uytpLlnCnNMBiIiIiIiIiIiIiIiIiIiI5BQlzUVEREREREREREREREREJN9S0lxERERERERERERERERERPItJc1FRERERERERERERERERCTfUtJcRERERERERERERERERETyLSXNRUREREREREREREREREQk31LSXERERERERERERERERERE8i0lzUVEREREREREREREREREJN9S0lxERERERERERERERERERPItJc1FRERERERERERERERERCTfUtJcRERERERERERERERERETyLSXNRUREREREREREREREREQk31LSXERERERERERERERERERE8i3HnA5AREREREREsp6rAzhZE3Gwmu5qO4Zh4GQkAeBoTcRkurvtAZisibg63PVmRERERERERETuiJLmIiIiIiIi+cDmjp5w7uPM3WhY5m1qYEdPeuzIvO2JiIiIiIiIiNwqDc8uIiIiIiIiIiIiIiIiIiL5lnqai4iIiIiI5ANNV0bxxKvv4eDsclfbMQyDa9euAVCwYMFMGZ7dkpjAilnvUsbf+663JSIiIiIiIiJyu5Q0FxERERERyQfiLZBkdsYwO9/VdgzDIMnkBECy2TlTkubJZoN4y11vRkRERERERETkjmh4dhERERERERERERERERERybeUNBcRERERERERERERERERkXxLSXMREREREREREREREREREcm3lDQXEREREREREREREREREZF8S0lzERERERERERERERERERHJt5Q0FxERERERERERERERERGRfEtJcxERERERERERERERERERybeUNBcRERERERERERERERERkXxLSXMREREREREREREREREREcm3lDQXEREREREREREREREREZF8S0lzERERERERERERERERERHJt5Q0FxERERERERERERERERGRfMsxpwO4F4SFhTFnzhw2bdrE+fPnMZlMlC5dmpYtW9K7d288PDxuWCcuLo7Zs2ezdu1azp8/j5eXF40aNaJfv36UKFEiw886c+YM06dPZ9u2bURGRlKsWDEeeeQRXnnlFdzc3LJyN0VERERERERE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qe455LRp0/TVV1+pffv2Wrx4serUqSNJOnLkiMaPH6+PP/5Y6enpevXVVyXJVhTPy8uTJA0cOFBz585Vz549FRQUZCuqO7owYDKZ+MdoNVfd/v54zwGupbr9PvIZUnM46+8xMzNTQ4cOVVZWlj755BM1bVp8ydbPP/9cFy5cKHN8RESE1q1bx80bAAAA1Yx9N6IqJ39/f0lSYmJiseNFd5AGBASocePGuv322xUZGVnl8QEAAMD1VOcc8siRI/r666/l4eGhmTNn2grmktS0aVO9++67kqSvv/5a586dk/TfZdmLlmkfOXKkdu/erffee0/Sf5dlL1qmHQAAAJX33Xff6cCBA7r77rtLFMwvV79+/RJfVqtVp06d0qpVq6owYgAAANiDU4rmDz74oAzD0KRJk/Tzzz8rJydHe/fu1SuvvCKTyaQHH3xQ0qW9HTdu3Gh7igYAAAC1V3XOIf/973+rsLBQ119/va655poS7W3btpW3t7fy8vL066+/SpIaNWokqeylSYtuHijqBwAAgMqLioqSyWTSkCFDrtgvOjq6xNfrr78uwzC0cePGKooWAAAA9uKU5dkff/xxHTt2TMuXLy+WgBqGoUceeUSPP/64JKlPnz5asmSJjh8/fsU7OwEAAFDzVeccMikp6U/7FC1BmpqaKkm2vTDj4+NlGEaJJUr37dsnSWrVqpU9QwUAAKjVDh48KHd39zJzLMMwyhx7//33KywsTAcPHnRUeAAAAHAQpxTNJSksLEwPPfSQNm3apAsXLig4OFjdunXTTTfdZOvj5eWlv/3tb84KEQAAAC6muuaQDRo0kCT95z//0dmzZ1W3bt1i7QcOHFBGRoak/z453q5dO1mtVp0/f147duxQu3btbP1TUlIUExMjSeratWtVvAQAAIBaISsrS97e3mW2jxo1SpmZmaW2eXp6ys/Pz5bXAQAAoPpwWtFcuvT0TNETNAAAAEB5VMcc8p577pG/v7/S0tI0adIkzZo1y1Y4P378uF577TVJUrNmzWyvzdfXV4MHD9bSpUs1depULV68WKGhocrJydGUKVOUlZWlli1bFiumAwAAoHKCgoJ07tw5paeny9fXt0T7k08+WebY7OxspaenKzg42JEhAgAAwAGcWjQHAAAAagN/f3/NmDFD48eP144dO9S1a1c1btxY+fn5On78uAoKChQYGKj3339fZrPZNm7ChAmKi4vTgQMH1KNHDzVr1kyJiYlKSUmRn5+fZs2aVWLZdgAAAFRcq1attHnzZq1Zs0aPPfbYVY1dt26dCgsL1bJlSwdFBwAAAEcx/3kXAAAAAJXVrVs3rV69WgMHDlRoaKh+//13nTp1So0bN9bIkSP1t7/9rcQFVh8fH4WHh2v06NEKDQ3VoUOHZLFY1L9/f0VGRqp58+ZOejUAAAA1U9++fWUYhmbPnq0ff/yx3ON++eUXzZw5UyaTSX379nVghAAAAHAEnjQHAAAAqkjTpk01bdq0qxrj7e2tiRMnauLEiQ6KCgAAAEXuv/9+LVu2THv27NGIESPUp08f9evXT7fccosCAgKK9U1PT9e+ffu0fv16rV69Wrm5uWrVqpX69+/vpOgBAABQUVVeNA8LC9Nzzz2nevXq2W3OtWvXqrCwUA888IDd5gQAAIDrIIcEAABAVfnkk080YsQIHTp0SGvXrtXatWslXVoFyMfHR5KUlZWlixcv2sYYhqHrr79eCxcuZPscAACAaqjKl2f/9ttv1bNnT/31r3/Vr7/+WuF5MjMzFRERob59+2rSpEn6/fff7RglAAAAXAk5JAAAAKpKcHCwVq1apaefflq+vr4yDEOGYSg9PV1nzpzRmTNnlJaWZjvu5eWlJ554QpGRkbrmmmucHT4AAAAqoMqfNH/33Xc1Z84chYeH65tvvtGNN96ovn376o477lDr1q3l4eFR5tjExET9/PPP+uGHHxQdHa3s7GxJUp8+fTRw4MCqegkAAACoYuSQAAAAqEoeHh6aMGGCnn32WW3dulV79+7V8ePHdfHiRRmGIT8/PzVq1Eht27ZVx44d5e3t7eyQAQAAUAlVXjR/6KGH1KdPH33++edaunSpDhw4YHtayM3NTU2aNFFwcLACAgJktVqVlpam1NRUHT9+XElJSZIuLXckSe3bt9f48eN10003VfXLAAAAQBUihwQAAIAzeHp6qnv37urevbuzQwEAAIADVXnRXLqUbI4ZM0ZPPfWU1q9fr5UrV2r37t3Kz8/XoUOHbP1MJpPt4maROnXqqF+/fnrkkUfUpEmTqg4dAAAATkIOCQAAAAAAAMARnFI0L+Lh4aH+/furf//+ysjI0E8//aQ9e/bo7NmzSkpKUk5OjgIDAxUcHKxmzZrpzjvv1P/7f//PmSEDAADAycghAQAAAAAAANiTU4vml/Px8VHnzp3VuXNnZ4cCAACAaoIcEgAAAAAAAEBlmZ0dAAAAAAAAAAAAAAAAzkLRHAAAAAAAAECtFxYWptOnT9t1zrVr1+pvf/ubXecEAACA/VE0BwAAAAAAAFDrffvtt+rZs6f++te/6tdff63wPJmZmYqIiFDfvn01adIk/f7773aMEgAAAI7gMnuaAwAAAAAAAICzvPvuu5ozZ47Cw8P1zTff6MYbb1Tfvn11xx13qHXr1vLw8ChzbGJion7++Wf98MMPio6OVnZ2tiSpT58+GjhwYFW9BAAAAFQQRXMAAAAAAAAAtd5DDz2kPn366PPPP9fSpUt14MAB2xPnbm5uatKkiYKDgxUQECCr1aq0tDSlpqbq+PHjSkpKkiQZhiFJat++vcaPH6+bbrrJaa8HAAAA5UfRHAAAAAAAAAAkeXp6asyYMXrqqae0fv16rVy5Urt371Z+fr4OHTpk62cymWwF8iJ16tRRv3799Mgjj6hJkyZVHToAAAAqgaI5AAAAAAAAAFzGw8ND/fv3V//+/ZWRkaGffvpJe/bs0dmzZ5WUlKScnBwFBgYqODhYzZo105133qn/9//+n7PDBgAAQAVRNAcAAAAAAACAMvj4+Khz587q3Lmzs0MBAACAg5idHQAAAAAAAAAAAAAAAM5C0RwAAAAAAAAAAAAAUGtRNAcAAAAAAAAAAAAA1FoUzQEAAAAAAAAAAAAAtZa7swMAAAAAADheUF6efNKS5eZhveqxuZ5eyvfwLLXNOy1FklGhmPI8PJXn6VWhsQAAAAAAAPZC0RwAAAAAaoGd+3ZK4x6o0Ni1T0zSrp6DSm0b99Jg+VxMqdC8/3z4L/rnwNEVGgsAAAAAAGAvLM8OAAAAAAAAAAAAAKi1KJoDAAAAAAAAAAAAAGotiuYAAAAAAAAAAAAAgFqLPc0BAAAAoBa4u+3demD063LzsF712FxPrzLbPn7/W0lGhWLK8/Cs0DgAAAAAAAB7omgOAAAAALVAssWiDP8guVegaH4lmf6Bdp0PAAAAAACgqrE8OwAAAAAAAAAAAACg1qJoDgAAAAAAAAAAAACotVxmefbc3FytXr1aW7du1e+//66MjAwVFhaW2d9kMmnTpk1VGCEAAABcDTkkAAAAAAAAgMpyiaL5uXPnNHLkSB09elSSZBjGn44xmUyODgsAAAAujBwSAAAAAAAAgD24RNH8gw8+0JEjR2QymdSxY0fdfvvtqlOnjtzc3JwdGgAAAFwUOSQAAAAAAAAAe3CJovnmzZtlMpk0ZcoUDR061NnhAAAAoBoghwQAAAAAAABgD2ZnByBJmZmZcnNz06BBg5wdCgAAAKoJckgAAAAAAAAA9uASRfOGDRvK09NTFovF2aEAAACgmiCHBAAAAAAAAGAPLlE079evnzIyMrR7925nhwIAAIBqghwSAAAAAAAAgD24RNH8qaee0p133qmXXnpJu3btcnY4AAAAqAbIIQEAAAAAAADYg7uzA5CksLAw1a9fX3v37tWIESPUuHFjXX/99QoKCipzjMlk0rvvvluFUQIAAMCVkEMCAADAnh5//HG7zGMymbRkyRK7zAUAAICq4RJF88jISJlMJhmGIUk6duyYjh07Vmrfon5c8AQAAKjdyCEBAABgT1davchkMtn+XJR/ltV2+fcAAACoHlyiaP7ggw+STAIAAOCqkEMCAADAnsaOHVvq8d9//11/+9vfJEktW7ZUmzZtFBwcLElKSkrS/v379csvv8hkMunBBx/UtddeW2UxAwDgyjzdTLIUFsi9sHK7RRuGIY/CAkmSpbDALteDTIUF8nTjuhL+yyWK5jNmzHB2CAAAAKhmyCEBAABgT6UVzY8dO6ZBgwbp2muv1ezZs3XTTTeVOnbv3r2aMGGC/vnPf+q7775zdKgAAFQL2/tfLx3bad9Jz9hvqr/2v16Px9pvPlRvlbu1AwAAAAAAAABqqA8//FDp6en64IMPyiyYS9JNN92kDz74QKmpqZo9e3YVRggAAAB7cIknzQEAAAAAAADA1ezcuVO+vr66+eab/7TvLbfcIl9fX+3YsaMKIgMAwPW1X3NMPV54RO4elkrNYxiGLqalSZL8/P3tsjx7fm6evp+7Qs3q/r9Kz4WawSWL5hcuXNDBgweVnJwsSQoKCtINN9ygkJAQJ0cGAAAAV0UOCQAAAHvLzs5WYWGh8vPz5e5+5Uup+fn5ys3NVUFBQRVFBwCAa8suMJRndpNhdqvUPIZhKPf/5sgzu9mnaG4uVHaBUel5UHO4VNF806ZNWrBggeLj40ttb9OmjZ555hl169atiiMDAACAqyKHBAAAgKNcf/31+vXXX/Xdd99pyJAhV+y7cuVK5ebmqmXLllUUHQAAAOzFZfY0nz17tsaNG6f9+/fLMIxSv/bt26exY8eyLxAAAAAkkUMCAADAsQYMGCDDMPT2229r5syZOnLkSIk+R44c0Xvvvadp06bJZDLp4YcfdkKkAAAAqAyXeNJ827ZtWrRokSSpQ4cOevTRR9W2bVsFBwdLkpKSkrRv3z6Fh4dr27Zt+uyzz3T33XerQ4cOzgwbAAAATkQOCQAAAEcbNmyYtm3bpi1btujLL7/Ul19+KavVqoCAAElSamqqcnJyJF1aOrZLly567LHHnBkyAAAAKsAlnjT/+uuvZTKZNHToUH3++efq3r27QkNDZbFYZLFYFBoaqu7du+vzzz/Xo48+KsMw9PXXXzs7bAAAADgROSQAAAAczc3NTZ988okmTJigwMBAGYah7OxsnTlzRmfOnFF2drYMw1BISIgmTZqkefPm2WWfVQAAAFQtl3jSfO/evTKbzRo/fvyf9h0/frxWrFihvXv32jWG06dP63//938VExOj06dPy2KxqEGDBmrfvr1eeOEFeXl5FeuflZWlBQsWaP369Tp16pQCAgLUsWNHjRs3Tg0bNizzPCdOnNDHH3+sbdu2KTU1VfXr19d9992nZ555psQ5AAAAUDZXyCEBAABQ87m7u+vpp5/WqFGjtHfvXh08eFBpaWmSpMDAQN14441q06aN3NzcnBwpAAAAKsoliuapqany9fWVv7//n/YNCAiQn5+fUlNT7Xb+HTt26LnnnlNGRoY8PT3VpEkT5efn68SJE/ryyy81cuTIYgXtzMxMPfbYY0pISJDValXz5s2VmJioyMhIRUdHKzw8XE2bNi1xnsOHD+vRRx9VWlqaAgMD1bx5cx0+fFgLFixQTEyMli1bRuEcAACgnJydQwIAAKB2cXd312233abbbrvN2aEAAADAzlxiefaAgAClp6fb7tC8ktTUVF28eNG2b1BlnTlzxlYwf/rppxUbG6vVq1dr7dq1iouL0xdffCFfX99iY+bMmaOEhAQ1adJE33//vSIiIrRlyxb17NlTqampmjRpUonzGIahSZMmKS0tTb169dKWLVsUERGhqKgoXX/99YqPj9ecOXPs8poAAABqA2fmkAAAAAAAAABqDpcomrdt21aFhYX66KOP/rTv3LlzVVhYqLZt29rl3PPmzVNGRoaGDh2qF198Ud7e3rY2Nzc3dejQoVjRPD09XStWrJAkhYWFKTQ0VJJktVr19ttvy8vLS/Hx8YqNjS12ntjYWCUkJMjb21vvvPOOrFarJKlevXp6/fXXJUnffPONMjIy7PK6AAAAajpn5pAAAACoffbu3atFixZp6tSpeu2114q15eTk6MyZMzp79qyTogMAAEBluETR/NFHH5VhGFq2bJmeeuop/fDDDzp79qwKCgpUUFCgs2fP6ocfftCoUaMUHh4uk8mkoUOHVvq8ubm5Wr9+vcxms5555plyjYmNjVV2drbq1Kmjdu3aFWsLDAxUp06dJEnR0dHF2oq+79y5c4klRDt27KigoCBlZ2eXKLYDAACgdM7KIQEAAFC7nDt3Tk888YQeeeQRffjhh1q+fLkiIyOL9UlJSVGPHj1077336tChQ06KFAAAABXlEnua33PPPXriiSf05ZdfauvWrdq6dWup/QzDkCQ98cQT6ty5c6XPe+DAAV28eFE33HCDQkNDtW7dOkVFRSklJUX169dXjx491LVr12Jj9u/fL0lq3bq1TCZTiTnbtm2rqKgoxcfHFzte9H1pTzeZzWa1adNGMTEx2r9/v7p3717p11YawzBsP0PY3+U/W37WqEl4b6Mm4n1dtRz183VWDgkAAIDaIyMjQyNGjNDRo0fl7++vtm3b6scff1Rubm6xfqGhoerXr58iIiK0YcMGNW/e3EkRAwDgOoLz8uWbliF3y9WXI3M8rcqzWkpt80nLkKmC15tyrR7K9fSo0FjUbC5RNJekV155RTfddJMWLVqkX375pdQ+rVq10ujRo9W7d2+7nPPw4cOSpMaNG+v111/Xd999V6w9IiJCvXv31uzZs+Xm5iZJOn78uCSpQYMGpc7ZsGHDYv2K/Nm4ouN/HGdPBQUFSk1Nddj8tV1hYaHtz2lpaTKbXWIhB6DSeG+jJuJ9XbUKCgocNrczckgAAADUHkuWLNHRo0d122236ZNPPlFgYKA6duyoCxculOjbv39/RUREaNeuXU6IFAAA1/PT3kPS09MrNPbbv/RXzH3tS217/YUP5JdWse2O1w3urvWP9KjQWNRsLlM0l6T77rtP9913n86ePauDBw/aCrwBAQG64YYbVLduXbueLyUlRZK0Z88enTt3TmPGjNGwYcPk5eWlf/7zn3rjjTe0YcMGtW3bVn/5y18kSRcvXpQk+fn5SZLCw8M1a9Ys9erVS9OnT7ftf56enl7sXH8c98orr+j777/Xq6++qsGDB9uO/3EcAAAArqyqc0gAAADUHlFRUTKZTHrjjTcUGBh4xb633nqrzGazjhw5UjXBAQAAwG5cqmhepG7dulVycTMrK0vSpX2J+vXrp+eff97W1qdPH128eFFhYWFavHixRo0aJZPJZFt6yWK5tCTEsmXLlJGRoYiICL388svy8Li0pEN2dnaxc10+LikpSatXr5YkLV26VIMHDy5znD25ubkpICDAYfPXdpc/Refv729bnQCo7nhvoybifV21Tp48WSXnqaocEgAAALXHb7/9JovFolatWv1pXw8PD/n5+SktLa0KIgMAAIA9uWTRvKoUFaol6dFHHy3R3rdvX7355ps6d+6cjh07piZNmtjG5OXlSZIGDhyouXPnqmfPngoKCrIVxz09PUucKzs7W3l5eQoODlbv3r0VExOjoUOHSlKZ4+zJZDKVug877OPyny0/a9QkvLdRE/G+rlr8fAEAAFCdlfcmW8MwlJWVJS8vLwdHBAAAAHurlkXzPXv2KC8vT3feeWel5vHx8bH9uUmTJiXafX19FRwcrAsXLuj06dNq0qSJbRn1ouXWR44cqZEjR9rGFC2vXrRMexE/Pz9lZ2fbxs2dO7dYe9HxP44DAACAfdgrhwQAAEDt0aBBAx09elTHjx9Xo0aNrtj3p59+Um5urpo1a2aXcy9evFh79uzR4cOHdfbsWWVmZqpOnTq6/fbb9eSTT6p169aljsvKytKCBQu0fv16nTp1SgEBAerYsaPGjRunhg0b2iU2AADK4/abmqvrmIFyt1x9OTLH01pm27S5E2UyjArFlGv1+PNOqJWqZdF87NixSkpKUkJCQqXmufbaa21/Llpu/Y8KCwuL/bcoOS5rmdHExMRi/Yo0atRI586dK3Nc0fE/S74BAABQMfbKIQEAAFB7dOjQQUeOHNHChQv1zjvvlNkvNzdXs2bNkslkUqdOnexy7unTp0uSrFar6tWrp4YNG+r48eNau3at1q9fr7ffflsPP/xwsTGZmZl67LHHlJCQIKvVqubNmysxMVGRkZGKjo5WeHi4mjZtapf4AAD4M0kWd6X7+8jdo/QaXEVl+Pv8eSfgKpmdHUBFGRW8g+RyN954o+3PpRWzc3NzlZycLEkKCQmRJLVp00aSFB8fX2oM+/btk6QS+xwV3flZ1H65wsJC7d+/v1g/AAAA2J89ckgAAADUHqNGjZKXl5dWrVqlsLAw/fbbb8Xac3NztXnzZg0ZMkQ///yzvL29NXz4cLuc+7nnntO3336rn3/+WVFRUYqMjFRsbKzGjRungoICvfXWWzpx4kSxMXPmzFFCQoKaNGmi77//XhEREdqyZYt69uyp1NRUTZo0yS6xAQAA1DTVtmhuD6GhobrhhhskSX//+99LtEdHR0u6tIx70bJK7dq1k9Vq1fnz57Vjx45i/VNSUhQTEyNJ6tq1a7G2Ll26SJK2bNmitLS0Ym1bt25VcnKyPD091a5dOzu8MgAAALiq06dP65133lGvXr10880364477tADDzygGTNmKCsrq0T/rKwsffjhh+rZs6fatm2rjh076tVXX7WtcAQAAADHCQ0N1fvvvy93d3etXLlSvXv31oULFyRJnTt31u23365nnnlGCQkJcnd316xZs1SnTh27nPuFF17QzTffLJPJZDtmsVg0duxYtWjRQrm5ufrhhx9sbenp6VqxYoUkKSwsTKGhoZIuPan+9ttvy8vLS/Hx8YqNjbVLfAAAADVJrS6aS9KIESMkScuWLdO2bdtsx0+ePKkPP/xQkjRw4EC5u19ayd7X11eDBw+WJE2dOlVnzpyRJOXk5GjKlCnKyspSy5YtSxS/27dvr5YtWyozM1NTpkxRTk6OpEsXTadNmyZJGjx4MHuaAwAA1GA7duxQnz59tHTpUp0+fVpNmjRRvXr1dOLECX355ZdKTU0t1j8zM1NDhw7VggULdPr0aTVv3lx5eXmKjIzUgAEDdOTIESe9EgAAgNqjW7duWr58uW655RYZhmH7Onv2rPLy8mQYhm6++WYtX768xIM0jtK4cWNJlx7iKRIbG6vs7GzVqVOnxLXJwMBA27LxRQ8KAQAA4L+qfE/zbt26SbqU2H3xxRfFjpVXUlKS3eJ56KGHtGXLFm3cuFFPPvmkmjRpIi8vLx06dEh5eXlq1aqVXnjhhWJjJkyYoLi4OB04cEA9evRQs2bNlJiYqJSUFPn5+dn2L7qcyWTSzJkzNXToUG3YsEE7duxQw4YNdfjwYeXk5Khly5aaMGGC3V4XAABATeJqOWRFnDlzRs8995wyMjL09NNP65lnnpG3t7ckqaCgQDt27ChxA+Xly2suWbJEoaGhysnJ0UsvvaSoqChNmjRJERERzng5AAAAtUrbtm0VHh6u3377Tbt379a5c+ckSXXq1NEtt9yiJk2aVFks2dnZti0gi1bHlFRs+8c/XpuULr2GqKgoxcfHOzS+opsKgMtd/p7gPQLUfNXtd5zPpZqhsn+HVV40L1pG0mq1FjtmMpmu6sWUlvhVhNls1pw5c/Ttt98qIiJChw8fVn5+vho3bqz77rtPTzzxhHx8fIqN8fHxUXh4uD799FNt2LBBhw4dUkBAgPr3769x48apUaNGpZ7rhhtuUGRkpD7++GNt375dhw4dUr169dS7d289++yztoumAAAAKM7VcsiKmDdvnjIyMjR06FC9+OKLxdrc3NzUoUOHYsf+bHnNmJgY2/KabPEDAABQNRo3bmx7yruqZWRk6ODBg/roo4908uRJtW7dWr169bK1Hz9+XJLUoEGDUsc3bNiwWD9HKSgoKLGCElBYWGj7c1pamszmWr8ILlCjZWdnKy8vT4VG4aVVWSp9Oea/137y8/MkVf76Tv5l8aWmptpWiEb1VVBQUKnxVV40nz59uiTJz8+v2HFfX1+99tpr5Zpj2rRpysjIsFtMZrNZQ4YM0ZAhQ8o9xtvbWxMnTtTEiROv6lyNGjXSzJkzrzZEAACAWs0Vc8irkZubq/Xr18tsNuuZZ54p15jyLK8ZFRWl6OhoiuYAAAA12KuvvqrIyEjb9wEBAfrLX/6i5557zralpCRdvHhR0n9z5vDwcM2aNUu9evXS9OnTbasapaenV2H0AAAA1UOVF80HDBhQ6nGr1Vpm2x998MEHTrvgCQAAgKpX3XPIAwcO6OLFi7rhhhsUGhqqdevWKSoqSikpKapfv7569OhRYv9LV1leEwAAAM51/fXX67bbblN6erqOHz+uixcv6tChQzp79myxZeFzc3MlSRaLRZK0bNkyZWRkKCIiQi+//LI8PDwkXXr6z5Hc3NwUEBDg0HOg+rn86T9/f3+5ubk5MRoAjma1WmWxWGQ2mWWxWOT+f/9vqqhLqwxmSZLc3S12WUnQZMgWX0BAgDw9PSs9J5zr5MmTlRpf5UVzAAAAoLY5fPiwpEvLeb7++uv67rvvirVHRESod+/emj17tu3ikassr8m+XtVfdfv74z0HuJbq9vvIZ0jNUV3+Hv/9739r3759cnd3V4cOHXT99dfb/RzPPPOMbbWi3NxcLVmyRB9++KGGDBmib7/91rZcfFFRPC8vT5I0cOBAzZ07Vz179lRQUJCtqO7oooDJZHLqtkhwTZe/J3iPADVfdfsd53OpZqjs36FLFM1DQkIUEhLi7DAAAABQjVSnHDIlJUWStGfPHp07d05jxozRsGHD5OXlpX/+85964403tGHDBrVt21Z/+ctfJLnO8prsSVn92faSKzT+by85++0fmZ+fb595LouPveQA1+LIzxDJPp8jfIbUTJXdk9Jedu3apenTp6t58+Yltlx8/fXXFRERYfvebDbrxRdf1KhRoxwWj4eHh5566ildvHhRCxcu1Jw5c/Thhx9K+m/eWJRHjhw5UiNHjrSNLcobi/JIAAAA/Jd9/6VTQdu2bdOaNWvK3T80NFT169d3YEQAAABwddUph8zKurSE2Llz59SvXz89//zzCg4OlpeXl/r06aNXXnlFkrR48WLbU1VXWl4zOTm5ypbXBAAAqM02btyoAwcO6I477ih2fP369Vq1apUMw9B1112nunXrqqCgQO+//75++uknh8dVtLVPXFyc7VijRo0klb00aWJiYrF+AAAA+C+XeNL8aq1atcrZIQAAAKCacWYOWVTglqRHH320RHvfvn315ptv6ty5czp27JiaNGniMstrsidl9WfbS85ssuNecpe4u7vbaS+5Qlt87CUHuBZ7f4ZI9v8c4TOkZqrsnpT28u9//1uSdO+99xY7vnbtWknSAw88oJkzZ8owDL322muKjIzU8uXLdfvttzs0royMDElSWlqa7VibNm0kSfHx8TIMo8Tv1r59+yRJrVq1cmhswB+dOXNGixYt0j/+8Q9lZmYqNDRUXbp00ahRoxQaGurs8AAAkFRNi+YAAABAdeLj42P7c5MmTUq0+/r6Kjg4WBcuXNDp06fVpEkTl1lek329qr/q9vfHew5wLdXt95HPkJrDVf4eT58+LW9vb9WtW7fY8R9//FEmk0njx4+XdCneCRMmKDIyUrt373Z4XJs3b5ZUPLds166drFarzp8/rx07dqhdu3a2tpSUFMXExEj671PqgKNlZWXphRde0OLFi203A0uXbuDYtGmT3nrrLT3xxBOaO3cuNzwBAJzOJZZnBwAAAGqya6+91vZnSxlP6BUWFhb7L8trAgAAON/FixdlNhe/hHrixAmlpqaqSZMmatCgge143bp15ePjo/Pnz1f6vMuWLdPHH3+s//znP8WO5+bm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JKnsJ60SExOL9QMAAMAlTima//jjj5Kk3r17X/XYHj166M0331RCQoK9wwIAAIALi4qKkslk0pAhQ67YLzo6usSxlStXasqUKdq4cSNFcwAAAFxRQUGBrbhckT7OfPqtaDui+Ph4GYZRIpZ9+/ZJklq1alXlsQEAALgypxTNz507J19fX4WEhFz12ODgYPn5+en06dMOiAwAAACu6uDBg3J3dy/zAt+V9pW8//77FRYWpoMHDzoqPAAAANQAAwYMcHYIldKuXTtZrVadP39eO3bsULt27WxtKSkpiomJkSR17drVWSECAAC4JKcUza1Wq3Jzcys8Pjc3V+7uTgkdAAAATpKVlSVvb+8y20eNGqXMzMxS2zw9PeXn56eMjAxHhQcAAIAaYPr06c4OoVJ8fX01ePBgLV26VFOnTtXixYsVGhqqnJwcTZkyRVlZWWrZsmWxYjoAAACcVDSvX7++Dh8+rAMHDqhFixZXNfaXX35RTk4O++4AAADUMkFBQTp37pzS09Pl6+tbov3JJ58sc2x2drbS09MVHBzsyBABAAAAu7v77rttfy56EOnf//53seOffPKJbr/9dknShAkTFBcXpwMHDqhHjx5q1qyZEhMTlZKSIj8/P82aNcupS8gDAAC4IrMzTtqhQwcZhqHZs2dfcRnN0syePVsmk0kdOnRwUHQAAABwRUXLsq9Zs+aqx65bt06FhYVq2bKlvcMCAAAAHColJcX2VbSyUn5+frHj+fn5tv4+Pj4KDw/X6NGjFRoaqkOHDslisah///6KjIxU8+bNnfVSAAAAXJZTnjQfNmyYwsPDFRMTo2eeeUZvvPHGnz45/ttvv2natGmKiYmRh4eHHnvssSqKFgAAAK6gb9+++te//qXZs2frxhtv1B133FGucb/88otmzpwpk8mkvn37OjhKAAAA1GRffvnlFbf8uffee9WmTRu7nvPXX3+96jHe3t6aOHGiJk6caNdYAAAAaiqnFM2vvfZaTZ48WW+99Za2bNmiXr16qU2bNrrpppt07bXXysfHR5KUkZGhEydOaO/evdq/f7/tqfSXX35Z1113nTNCBwAAgJPcf//9WrZsmfbs2aMRI0aoT58+6tevn2655RYFBAQU65uenq59+/Zp/fr1Wr16tXJzc9WqVSv179/fSdEDAACgOsjMzNTQoUOVlZWlTz75RE2bNi3W/vnnn+vChQtljo+IiNC6devk6enp6FABAABgR04pmkvSkCFD5OnpqWnTpunixYvau3ev9u3bV2Z/wzDk7e2tyZMna9CgQVUYKQAAAFzFJ598ohEjRujQoUNau3at1q5dK+nSEpRFN15mZWXp4sWLtjGGYej666/XwoUL2bsRAAAAV/Tdd9/pwIEDGjx4cImC+eXq169f4lhSUpJOnTqlVatWsUomAABANeO0orkkPfjgg+rUqZNWrFihjRs36vDhwyooKCjWx2w2q2nTpurVq5cGDx6sunXrOilaAAAAOFtwcLBWrVql+fPna/ny5bbieHp6utLT00v09/b21iOPPKLnn39eXl5eVR0uAAAAqpmoqCiZTCYNGTLkiv2io6NLHFu5cqWmTJmijRs3UjQHAACoZpxaNJekkJAQPffcc3ruueeUnZ2txMREpaenyzAM+fn5qUGDBlzgBAAAgI2Hh4cmTJigZ599Vlu3btXevXt1/PhxXbx40ZZDNmrUSG3btlXHjh3l7e3t7JABAABQTRw8eFDu7u5q1apVqe1F20eW5v7771dYWJgOHjzoqPAAAADgIE4vml/O09PzisseAQAAAEU8PT3VvXt3de/e3dmhAAAAoIbIysq64k2Xo0aNUmZmZqltnp6e8vPzU0ZGhqPCAwAAgIO4VNEcAAAAAAAAAJwlKChI586dU3p6unx9fUu0P/nkk2WOzc7OVnp6uoKDgx0ZIgAAABzA7OwAAAAAAAAAAMAVFC3LvmbNmqseu27dOhUWFqply5b2DgsAAAAORtEcAAAAAAAAACT17dtXhmFo9uzZ+vHHH8s97pdfftHMmTNlMpnUt29fB0YIAAAAR6BoDgAAAAAAAACS7r//ft1yyy3KyMjQiBEjNGnSJG3evFmpqakl+qanpys2NlZhYWF65JFHlJKSopYtW6p///5OiBwAAACVwZ7mAAAAAAAAAPB/PvnkE40YMUKHDh3S2rVrtXbtWkmSj4+PfHx8JElZWVm6ePGibYxhGLr++uu1cOFCmUwmp8QNAACAiqNoDtjBmTNntGjRIv3jH/9QZmamQkND1aVLF40aNUqhoaHODg8AcBk+swEAAABcSXBwsFatWqX58+dr+fLltuJ4enq60tPTS/T39vbWI488oueff15eXl5VHS4AAADsgKI5UAlZWVl64YUXtHjxYuXl5dmO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", - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "# Generate the full metrics plot using the module function\n", - "# This saves the plot to a file and returns the path\n", - "output_path = generate_metrics_plot(METRICS_PATH, \"/tmp/vis.png\", \"test\")\n", - "\n", - "\n", - "print(f\"Plot saved to: {output_path}\")\n", - "\n", - "# Display the saved image\n", - "from IPython.display import Image, display\n", - "\n", - "display(Image(filename=str(output_path)))" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Explore Data Interactively\n", - "\n", - "The cells below allow you to explore the raw data in more detail.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Explore histogram data\n", - "histograms_df" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Explore gauge data\n", - "gauges_df" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Filter to specific metrics\n", - "all_methods = [m for methods in METHODS_TO_PLOT.values() for m in methods]\n", - "\n", - "# RPC Duration for specific methods\n", - "rpc_duration = histograms_df.filter(\n", - " pl.col(\"metric\") == \"rpc_duration_seconds\",\n", - " pl.col(\"method\").is_in(all_methods) if \"method\" in histograms_df.columns else True,\n", - ")\n", - "rpc_duration" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# CPU utilization stats\n", - "cpu_stats = gauges_df.filter(pl.col(\"metric\") == \"process_cpu_utilization\")\n", - "cpu_stats" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# GPU stats\n", - "gpu_stats = gauges_df.filter(\n", - " pl.col(\"metric\").is_in(\n", - " [\"gpu_memory_used_bytes\", \"gpu_utilization\", \"gpu_memory_total_bytes\"]\n", - " )\n", - ")\n", - "gpu_stats" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": ".venv", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.11.13" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/src/runtime/alpasim_runtime/telemetry/plot_metrics.py b/src/runtime/alpasim_runtime/telemetry/plot_metrics.py index 63f3003a..f875477d 100644 --- a/src/runtime/alpasim_runtime/telemetry/plot_metrics.py +++ b/src/runtime/alpasim_runtime/telemetry/plot_metrics.py @@ -1,795 +1,322 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright (c) 2025-2026 NVIDIA Corporation -""" -Generate metrics analysis plots from Prometheus .prom files. - -This module produces a 3x3 grid of plots for analyzing simulation performance: -1. RPC Duration histogram -2. RPC Blocking histogram -3. RPC Queue Depth histogram -4. Rollout Duration histogram -5. Step Duration histogram -6. Service Configuration summary -7. CPU Utilization boxplot -8. GPU Utilization boxplot -9. GPU Memory boxplot -""" +"""Plot the Grafana dashboard's summary queries from Prometheus.""" +from __future__ import annotations + +import json import logging import math +import re +import time from dataclasses import dataclass +from datetime import datetime +from importlib.resources import files as resource_files from pathlib import Path -from typing import Any, Iterator +from typing import Any +from urllib.parse import urlencode, urlparse +from urllib.request import urlopen +import matplotlib.dates as mdates import matplotlib.pyplot as plt -import polars as pl -import seaborn as sns +import numpy as np import yaml -from prometheus_client.metrics_core import Metric -from prometheus_client.parser import text_string_to_metric_families logger = logging.getLogger(__name__) -sns.set_theme(style="whitegrid", palette="deep") - - -# Methods to plot (by service) -METHODS_TO_PLOT = { - "controller": ["run_controller_and_vehicle"], - "driver": [ - "drive", - "submit_egomotion_observation", - "submit_image_observation", - "submit_route", - ], - "physics": ["ground_intersection"], - "sensorsim": ["render_rgb"], -} - - -# --- Prometheus parsing --- +DASHBOARD_NAME = "alpasim-runtime-dashboard.json" +SUMMARY_ROW = "Metrics plot summary" +_LEGEND_LABEL = re.compile(r"{{([^{}]+)}}") +_REGEX_META = re.compile(r"([\\.*+?()\[\]{}|^$])") +_UNRESOLVED_VARIABLE = re.compile(r"\$(?:__)?[A-Za-z_][A-Za-z0-9_]*") @dataclass -class HistogramData: - """Parsed histogram data with buckets and summary stats.""" - - name: str - labels: dict[str, str] - buckets: list[tuple[float, int]] # (le, cumulative_count) - sum: float - count: int - - -@dataclass -class GaugeData: - """Parsed gauge data.""" - - name: str - labels: dict[str, str] - value: float - - -def _load_prometheus_file(path: Path) -> Iterator[Metric]: - """Load a .prom file and yield metric families.""" - with open(path, encoding="utf-8") as f: - text = f.read() - yield from text_string_to_metric_families(text) - - -def _extract_histograms(path: Path) -> list[HistogramData]: - """Extract all histograms from a .prom file.""" - results = [] - - for family in _load_prometheus_file(path): - if family.type != "histogram": - continue - - # Group samples by label set (excluding 'le') - by_labels: dict[tuple, dict] = {} - - for sample in family.samples: - # Extract labels without 'le' for grouping - labels = {k: v for k, v in sample.labels.items() if k != "le"} - key = tuple(sorted(labels.items())) - - if key not in by_labels: - by_labels[key] = { - "labels": labels, - "buckets": [], - "sum": 0.0, - "count": 0, - } - - if sample.name.endswith("_bucket"): - le = float(sample.labels.get("le", "inf")) - by_labels[key]["buckets"].append((le, int(sample.value))) - elif sample.name.endswith("_sum"): - by_labels[key]["sum"] = sample.value - elif sample.name.endswith("_count"): - by_labels[key]["count"] = int(sample.value) - - for data in by_labels.values(): - results.append( - HistogramData( - name=family.name, - labels=data["labels"], - buckets=sorted(data["buckets"]), - sum=data["sum"], - count=data["count"], - ) +class PrometheusClient: + base_url: str + timeout_s: float = 5.0 + + def query_range( + self, + query: str, + *, + start: float, + end: float, + step: str, + ) -> list[dict[str, Any]]: + parsed = urlparse(self.base_url) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise ValueError( + f"Unsupported Prometheus URL '{self.base_url}'. " + "Expected http(s)://host[:port]" ) - - return results - - -def _extract_gauges(path: Path) -> list[GaugeData]: - """Extract all gauges from a .prom file.""" - results = [] - - for family in _load_prometheus_file(path): - if family.type != "gauge": + params = urlencode({"query": query, "start": start, "end": end, "step": step}) + url = f"{self.base_url.rstrip('/')}/api/v1/query_range?{params}" + with urlopen(url, timeout=self.timeout_s) as response: + payload = json.loads(response.read().decode("utf-8")) + if payload["status"] != "success": + raise RuntimeError(f"Prometheus query failed: {payload}") + if payload["data"]["resultType"] != "matrix": + raise RuntimeError(f"Expected Prometheus matrix response: {payload}") + return payload["data"]["result"] + + +def _load_run_metadata(log_dir: Path) -> dict[str, Any]: + with open(log_dir / "run_metadata.yaml", encoding="utf-8") as f: + metadata = yaml.safe_load(f) + if not isinstance(metadata, dict): + raise TypeError("run_metadata.yaml must contain a mapping") + return metadata + + +def _load_summary_panels() -> list[dict[str, Any]]: + dashboard_path = resource_files("alpasim_utils.telemetry").joinpath(DASHBOARD_NAME) + dashboard = json.loads(dashboard_path.read_text(encoding="utf-8")) + panels = [] + found_summary = False + for panel in dashboard["panels"]: + if panel["type"] == "row": + if found_summary: + break + found_summary = panel["title"] == SUMMARY_ROW continue - - for sample in family.samples: - results.append( - GaugeData( - name=family.name, - labels=dict(sample.labels), - value=sample.value, - ) - ) - - return results - - -# --- DataFrame building --- - - -def _build_histograms_dataframe(histograms: list[HistogramData]) -> pl.DataFrame: - """Convert histogram data to a polars DataFrame with per-bucket counts.""" - # Collect all unique label keys - all_label_keys: set[str] = set() - for h in histograms: - all_label_keys.update(h.labels.keys()) - - # Create rows - rows = [] - for h in histograms: - for bucket_le, bucket_count in h.buckets: - row: dict[str, Any] = { - "metric": h.name, - "bucket_le": bucket_le, - "bucket_count": bucket_count, - "sum": h.sum, - "count": h.count, - } - for key in all_label_keys: - row[key] = h.labels.get(key) - rows.append(row) - - df = pl.DataFrame(rows, infer_schema_length=None) - - # Keep original numeric bucket values for calculations, and create formatted - # labels for display (using ≤ prefix prevents matplotlib from treating them - # as parsable floats which would trigger a warning) - df = df.with_columns( - pl.col("bucket_le").alias("bucket_le_num"), - pl.col("bucket_le") - .map_elements(lambda x: f" {x}", return_dtype=pl.Utf8) - .alias("bucket_le"), + if found_summary: + if panel["type"] not in {"timeseries", "heatmap", "histogram"}: + raise ValueError(f"Unsupported summary panel type: {panel['type']}") + panels.append(panel) + if not panels: + raise ValueError(f"Dashboard row '{SUMMARY_ROW}' has no panels") + return sorted( + panels, key=lambda panel: (panel["gridPos"]["y"], panel["gridPos"]["x"]) ) - # Compute per-bucket counts (convert cumulative to individual) - # Sort by numeric bucket value to ensure correct ordering - sort_cols = ["metric", "service", "method", "worker_id", "bucket_le_num"] - # Only use columns that exist - sort_cols = [c for c in sort_cols if c in df.columns] - group_cols = [c for c in sort_cols if c != "bucket_le_num"] - - df = df.sort(sort_cols).with_columns( - pl.col("bucket_count") - .diff() - .over(group_cols) - .fill_null(pl.col("bucket_count")) - .alias("per_bucket_count") - ) - - return df - - -def _build_gauges_dataframe(gauges: list[GaugeData]) -> pl.DataFrame: - """Convert gauge data to a polars DataFrame.""" - # Collect all unique label keys - all_label_keys: set[str] = set() - for g in gauges: - all_label_keys.update(g.labels.keys()) - # Create rows - rows = [] - for g in gauges: - row: dict[str, Any] = { - "metric": g.name, - "value": g.value, - } - for key in all_label_keys: - row[key] = g.labels.get(key) - rows.append(row) - - df = pl.DataFrame(rows, infer_schema_length=None) - - # Filter out "created" metrics - df = df.filter( - ~pl.col("metric").is_in( - [ - "rpc_duration_seconds_created", - "rpc_blocking_seconds_created", - "rpc_queue_depth_at_start_created", - "rollout_duration_seconds_created", - "step_duration_seconds_created", - ] +def _promql_regex(value: object) -> str: + quoted = _REGEX_META.sub(r"\\\1", str(value)) + return json.dumps(quoted)[1:-1] + + +def _resolve_query( + query: str, + *, + run_uuid: object, + run_name: object, + range_seconds: int, +) -> str: + resolved = query.replace("$run_uuid", _promql_regex(run_uuid)) + resolved = resolved.replace("$run_name", _promql_regex(run_name)) + resolved = resolved.replace("$__range", f"{range_seconds}s") + unresolved = _UNRESOLVED_VARIABLE.search(resolved) + if unresolved: + raise ValueError(f"Unsupported dashboard variable: {unresolved.group()}") + return resolved + + +def _legend(legend_format: str, metric: dict[str, str]) -> str: + if legend_format: + return _LEGEND_LABEL.sub( + lambda match: metric.get(match.group(1), match.group(0)), + legend_format, ) - ) - - return df - + labels = [ + f"{name}={value}" + for name, value in sorted(metric.items()) + if name not in {"__name__", "run_name", "run_uuid"} + ] + return ", ".join(labels) or metric.get("__name__", "value") -# --- Plotting helpers --- - -def _normalize_histogram_by_hue(df: pl.DataFrame, hue_col: str | None) -> pl.DataFrame: - """Normalize histogram counts per hue group so each group sums to 1. - - Args: - df: DataFrame containing the histogram data - hue_col: Column name to group by. If None, normalize across all data. - Returns: - DataFrame with normalized histogram counts - """ - if df.is_empty(): - return df - - if hue_col is None: - # Normalize across all data - total = df["per_bucket_count"].sum() - if total > 0: - return df.with_columns( - (pl.col("per_bucket_count") / total).alias("per_bucket_count") - ) - return df - - # Normalize within each hue group - return df.with_columns( +def _series_points(series: dict[str, Any]) -> tuple[list[float], list[float]]: + points = [ ( - pl.col("per_bucket_count") / pl.col("per_bucket_count").sum().over(hue_col) - ).alias("per_bucket_count") - ) - - -def _plot_cpu_boxplots(ax: plt.Axes, gauges_df: pl.DataFrame) -> None: - """Plot CPU utilization boxplots from pre-computed stats.""" - cpu_stats = gauges_df.filter(pl.col("metric") == "process_cpu_utilization") - - if cpu_stats.is_empty() or "name" not in cpu_stats.columns: - ax.text( - 0.5, 0.5, "No CPU data", ha="center", va="center", transform=ax.transAxes - ) - ax.set_ylabel("CPU Utilization") - return - - names = cpu_stats["name"].unique() - plotted_names = [] - boxes = [] - - for name in names: - s = { - row["stat"]: row["value"] - for row in cpu_stats.filter(pl.col("name") == name).to_dicts() - } - if not s or s.get("mean", 0) < 50: - continue - plotted_names.append(name) - box_stat = dict( - whislo=s.get("p05", 0), - q1=s.get("p25", 0), - med=s.get("p50", 0), - q3=s.get("p75", 0), - whishi=s.get("p95", 0), - mean=s.get("mean", 0), - fliers=[s.get("min", 0), s.get("max", 0)], + float(mdates.date2num(datetime.fromtimestamp(float(timestamp)))), + float(value), ) - boxes.append(box_stat) - - if not boxes: - ax.text( - 0.5, 0.5, "No CPU data", ha="center", va="center", transform=ax.transAxes - ) - ax.set_ylabel("CPU Utilization (%)") - return - - bp = ax.bxp(boxes, showmeans=True, meanline=True, patch_artist=True) - for box in bp["boxes"]: - box.set(facecolor="steelblue", alpha=0.7) - for mean in bp["means"]: - mean.set(color="red", linestyle="--", linewidth=2) + for timestamp, value in series["values"] + if math.isfinite(float(value)) + ] + return [point[0] for point in points], [point[1] for point in points] - ax.set_ylabel("CPU Utilization (%)") - ax.set_xticklabels(plotted_names, rotation=45, ha="right") - ax.grid(axis="y", alpha=0.3) - -def _plot_gpu_boxplots( - ax_util: plt.Axes, ax_mem: plt.Axes, gauges_df: pl.DataFrame +def _plot_timeseries( + ax: plt.Axes, + panel: dict[str, Any], + results: list[tuple[dict[str, Any], list[dict[str, Any]]]], ) -> None: - """Plot GPU utilization and memory boxplots from pre-computed stats.""" - gpu_memory_stats = gauges_df.filter(pl.col("metric") == "gpu_memory_used_bytes") - gpu_util_stats = gauges_df.filter(pl.col("metric") == "gpu_utilization") - gpu_memory_total = gauges_df.filter(pl.col("metric") == "gpu_memory_total_bytes") - - if gpu_memory_stats.is_empty() or "gpu" not in gpu_memory_stats.columns: - ax_util.text( - 0.5, - 0.5, - "No GPU data", - ha="center", - va="center", - transform=ax_util.transAxes, - ) - ax_mem.text( - 0.5, - 0.5, - "No GPU data", - ha="center", - va="center", - transform=ax_mem.transAxes, - ) - ax_util.set_ylabel("GPU Utilization (%)") - ax_mem.set_ylabel("GPU Memory Used (GB)") - return - - gpus = sorted(gpu_memory_stats["gpu"].unique()) - plotted_labels = [f"GPU {gpu}" for gpu in gpus] - - bytes_to_gb = 1024**3 - - # Get total memory per GPU (for horizontal line) - total_memory_by_gpu = {} - if not gpu_memory_total.is_empty() and "gpu" in gpu_memory_total.columns: - for row in gpu_memory_total.to_dicts(): - total_memory_by_gpu[row["gpu"]] = row["value"] / bytes_to_gb - - # GPU Memory plot - memory_boxes = [] - for gpu in gpus: - s = { - row["stat"]: row["value"] - for row in gpu_memory_stats.filter(pl.col("gpu") == gpu).to_dicts() - } - box_stat = dict( - whislo=s.get("p05", 0) / bytes_to_gb, - q1=s.get("p25", 0) / bytes_to_gb, - med=s.get("p50", 0) / bytes_to_gb, - q3=s.get("p75", 0) / bytes_to_gb, - whishi=s.get("p95", 0) / bytes_to_gb, - mean=s.get("mean", 0) / bytes_to_gb, - fliers=[s.get("min", 0) / bytes_to_gb, s.get("max", 0) / bytes_to_gb], - ) - memory_boxes.append(box_stat) - - if memory_boxes: - bp1 = ax_mem.bxp(memory_boxes, showmeans=True, meanline=True, patch_artist=True) - for box in bp1["boxes"]: - box.set(facecolor="seagreen", alpha=0.7) - for mean in bp1["means"]: - mean.set(color="red", linestyle="--", linewidth=2) - ax_mem.set_xticklabels(plotted_labels) - - # Draw horizontal line at max (total) GPU memory - if total_memory_by_gpu: - # Use the max total memory across all GPUs for the line - max_total_gb = max(total_memory_by_gpu.values()) - ax_mem.axhline( - y=max_total_gb, - color="darkred", - linestyle=":", - linewidth=2, - label=f"Total: {max_total_gb:.1f} GB", + lines = 0 + for target, series_list in results: + for series in series_list: + timestamps, values = _series_points(series) + if not timestamps: + continue + ax.plot( + timestamps, + values, + label=_legend(target.get("legendFormat", ""), series["metric"]), ) - ax_mem.legend(loc="upper right", fontsize=8) - - ax_mem.set_ylabel("GPU Memory Used (GB)") - ax_mem.grid(axis="y", alpha=0.3) - - # GPU Utilization plot - util_boxes = [] - for gpu in gpus: - s = { - row["stat"]: row["value"] - for row in gpu_util_stats.filter(pl.col("gpu") == gpu).to_dicts() - } - box_stat = dict( - whislo=s.get("p05", 0), - q1=s.get("p25", 0), - med=s.get("p50", 0), - q3=s.get("p75", 0), - whishi=s.get("p95", 0), - mean=s.get("mean", 0), - fliers=[s.get("min", 0), s.get("max", 0)], - ) - util_boxes.append(box_stat) - - if util_boxes: - bp2 = ax_util.bxp(util_boxes, showmeans=True, meanline=True, patch_artist=True) - for box in bp2["boxes"]: - box.set(facecolor="steelblue", alpha=0.7) - for mean in bp2["means"]: - mean.set(color="red", linestyle="--", linewidth=2) - ax_util.set_xticklabels(plotted_labels) - ax_util.set_ylabel("GPU Utilization (%)") - ax_util.grid(axis="y", alpha=0.3) - - -def _compute_summary_stats(gauges_df: pl.DataFrame) -> tuple[float, float]: - """Compute simulation summary statistics from gauges.""" - # Only group by columns that exist in the DataFrame - group_cols = ["metric", "method", "service", "name", "gpu", "stat"] - existing_cols = [c for c in group_cols if c in gauges_df.columns] - gauges_avg = gauges_df.group_by(existing_cols, maintain_order=True).agg( - pl.col("value").mean().alias("avg_value"), - pl.col("value").sum().alias("sum"), - ) - - gauge_avg = {row["metric"]: row["avg_value"] for row in gauges_avg.to_dicts()} - gauge_sum = {row["metric"]: row["sum"] for row in gauges_avg.to_dicts()} - - # Simulation seconds per rollout - total_seconds = gauge_avg.get("simulation_total_seconds", 0) - rollout_count = gauge_sum.get("simulation_rollout_count", 1) - sim_seconds_per_rollout = total_seconds / rollout_count if rollout_count else 0 - - # Event loop idle percentage - idle_time = gauge_avg.get("event_loop_idle_seconds_total", 0) - work_time = gauge_avg.get("event_loop_work_seconds_total", 0) - poll_time = gauge_avg.get("event_loop_poll_seconds_total", 0) - total_time = idle_time + work_time + poll_time - idle_percentage = idle_time / total_time if total_time > 0 else 0 - - return idle_percentage, sim_seconds_per_rollout - - -def _load_service_config(metrics_path: Path) -> dict[str, dict[str, Any]] | None: - """Load service configuration from wizard_config.yaml. - - Args: - metrics_path: Path to the metrics .prom file - - Returns: - Dictionary mapping service names to their configuration, or None if not found - """ - # Look for wizard_config.yaml in log_dir (metrics_path is /telemetry/metrics.prom) - config_path = metrics_path.parent.parent / "wizard-config.yaml" + lines += 1 + if lines: + ax.legend(fontsize=7) + else: + ax.text(0.5, 0.5, "No data", ha="center", va="center", transform=ax.transAxes) + ax.set_title(panel["title"]) + ax.grid(axis="both", alpha=0.3) + ax.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M")) + + +def _plot_heatmap( + ax: plt.Axes, + panel: dict[str, Any], + results: list[tuple[dict[str, Any], list[dict[str, Any]]]], +) -> None: + if len(results) != 1: + raise ValueError(f"Heatmap '{panel['title']}' must have exactly one target") - try: - with open(config_path, encoding="utf-8") as f: - config = yaml.safe_load(f) - except (OSError, yaml.YAMLError) as e: - logger.warning( - "Failed to load wizard_config.yaml from %s: %s", config_path.absolute(), e - ) - return None - - if not config: - return None - - services_config = config.get("services", {}) - runtime_config = config.get("runtime", {}) - endpoints_config = runtime_config.get("endpoints", {}) - - result = {} - for service_name in ["sensorsim", "driver", "physics", "controller", "trafficsim"]: - svc = services_config.get(service_name, {}) - endpoint = endpoints_config.get(service_name, {}) - - gpus = svc.get("gpus") - # nr_gpus: count of GPU list, or 1 for CPU-only services (null gpus) - if isinstance(gpus, list): - nr_gpus = len(gpus) - elif gpus is None: - nr_gpus = 1 # CPU-only service like controller - else: - nr_gpus = 0 - - replicas = svc.get("replicas_per_container", 1) - concurrent = endpoint.get("n_concurrent_rollouts", 1) - skip = endpoint.get("skip", False) - - result[service_name] = { - "nr_gpus": nr_gpus, - "replicas_per_container": replicas, - "n_concurrent_rollouts": concurrent, - "skip": skip, - "total": nr_gpus * replicas * concurrent, + bucket_values: dict[float, dict[float, float]] = {} + for series in results[0][1]: + boundary = float(series["metric"]["le"]) + if math.isinf(boundary): + continue + if boundary in bucket_values: + raise ValueError( + f"Heatmap '{panel['title']}' has duplicate bucket {boundary}" + ) + bucket_values[boundary] = { + float(timestamp): float(value) for timestamp, value in series["values"] } - return result - - -def _plot_service_config(ax: plt.Axes, metrics_path: Path) -> None: - """Plot service configuration summary in the given axes.""" - config = _load_service_config(metrics_path) - - if config is None: - ax.text( - 0.5, - 0.5, - "No wizard_config.yaml found", - ha="center", - va="center", - transform=ax.transAxes, - fontsize=10, - family="monospace", - ) - ax.axis("off") + if not bucket_values: + ax.text(0.5, 0.5, "No data", ha="center", va="center", transform=ax.transAxes) + ax.set_title(panel["title"]) return - service_abbrevs = { - "sensorsim": "SENS", - "driver": "DRIV", - "physics": "PHYS", - "controller": "CONT", - "trafficsim": "TRAF", - } - - # Build table data - col_labels = ["Service", "GPUs", "Replicas", "Concurrent", "Total"] - table_data = [] - - for service_name, abbrev in service_abbrevs.items(): - svc = config.get(service_name, {}) - if svc.get("skip", False): - table_data.append([abbrev, "-", "-", "-", "(skipped)"]) - else: - nr_gpus = svc.get("nr_gpus", 0) - replicas = svc.get("replicas_per_container", 0) - concurrent = svc.get("n_concurrent_rollouts", 0) - total = svc.get("total", 0) - table_data.append( - [abbrev, str(nr_gpus), str(replicas), str(concurrent), str(total)] - ) - - ax.axis("off") - ax.set_title( - "Service Configuration and \n number of parallel rollouts", - fontsize=11, - fontweight="bold", - loc="center", - y=0.80, + boundaries = sorted(bucket_values) + timestamps = sorted( + {timestamp for values in bucket_values.values() for timestamp in values} ) - - table = ax.table( - cellText=table_data, - colLabels=col_labels, - loc="center", - cellLoc="center", + cumulative = np.array( + [ + [bucket_values[boundary].get(timestamp, np.nan) for timestamp in timestamps] + for boundary in boundaries + ] ) - table.auto_set_font_size(False) - table.set_fontsize(8) - table.scale(1.0, 1.4) - - # Style header row - for col_idx in range(len(col_labels)): - table[(0, col_idx)].set_facecolor("#d0d0d0") - table[(0, col_idx)].set_text_props(fontweight="bold") - - # Style data rows with wheat background (same as legends) - for row_idx in range(1, len(table_data) + 1): - for col_idx in range(len(col_labels)): - table[(row_idx, col_idx)].set_facecolor("wheat") - - -# --- Main plotting function --- - - -def generate_metrics_plot( - metrics_path: Path, output_path: Path | None = None, run_name: str | None = None -) -> Path: - """ - Generate metrics analysis plot from a Prometheus .prom file. - - Args: - metrics_path: Path to the merged metrics.prom file - output_path: Optional output path for the PNG. Defaults to metrics_path.parent / "metrics_plot.png" - - Returns: - Path to the generated PNG file - """ - if output_path is None: - output_path = metrics_path.parent / "metrics_plot.png" - - logger.info("Loading metrics from: %s", metrics_path) - histograms = _extract_histograms(metrics_path) - gauges = _extract_gauges(metrics_path) - - if not histograms and not gauges: - logger.warning("No metrics found in file") - return output_path - - histograms_df = _build_histograms_dataframe(histograms) - gauges_df = _build_gauges_dataframe(gauges) - - # Compute summary stats - idle_percentage, sim_seconds_per_rollout = _compute_summary_stats(gauges_df) - - # Configure matplotlib - plt.rcParams["figure.figsize"] = (16, 12) - plt.rcParams["font.size"] = 10 - plt.rcParams["font.family"] = "monospace" - plt.rcParams["legend.facecolor"] = "wheat" - plt.rcParams["legend.edgecolor"] = "black" - plt.rcParams["legend.framealpha"] = 0.7 - plt.rcParams["legend.fontsize"] = 8 - - # Get methods to filter - all_methods = [m for methods in METHODS_TO_PLOT.values() for m in methods] - - # Filter data for RPC plots - df_duration = histograms_df.filter( - pl.col("metric") == "rpc_duration_seconds", - ( - pl.col("method").is_in(all_methods) - if "method" in histograms_df.columns - else True - ), + counts = np.maximum( + np.diff(cumulative, axis=0, prepend=np.zeros((1, len(timestamps)))), + 0, ) - df_blocking = histograms_df.filter( - pl.col("metric") == "rpc_blocking_seconds", - ( - pl.col("method").is_in(all_methods) - if "method" in histograms_df.columns - else True - ), - ) - - # Create figure - fig, axs = plt.subplots(ncols=3, nrows=3, figsize=(16, 12)) - axs = axs.flatten() - - # Plot 0: RPC Duration - ax = axs[0] - hue_col = "method" if "method" in df_duration.columns else None - df_duration_norm = _normalize_histogram_by_hue(df_duration, hue_col) - if sns and not df_duration_norm.is_empty(): - sns.barplot( - data=df_duration_norm.to_pandas(), - y="per_bucket_count", - x="bucket_le", - hue=hue_col, - dodge=True, - errorbar=None, - ax=ax, - ) - if ax.get_legend(): - ax.get_legend().remove() - ax.set_ylabel("RPC Duration (s)") - ax.set_xlabel("") - - # Plot 1: RPC Blocking - ax = axs[1] - hue_col = "method" if "method" in df_blocking.columns else None - df_blocking_norm = _normalize_histogram_by_hue(df_blocking, hue_col) - if sns and not df_blocking_norm.is_empty(): - sns.barplot( - data=df_blocking_norm.to_pandas(), - y="per_bucket_count", - x="bucket_le", - hue=hue_col, - dodge=True, - errorbar=None, - ax=ax, - ) - ax.set_ylabel("RPC Blocking (s)") - ax.set_xlabel("") + image = ax.imshow(counts, aspect="auto", origin="lower", interpolation="nearest") + ax.figure.colorbar(image, ax=ax, label="observations/s") - # Plot 2: RPC Queue Depth - ax = axs[2] - queue_depth_df = histograms_df.filter( - pl.col("metric") == "rpc_queue_depth_at_start" + tick_indices = np.unique( + np.linspace(0, len(timestamps) - 1, min(6, len(timestamps)), dtype=int) ) - hue_col = "service" if "service" in queue_depth_df.columns else None - queue_depth_norm = _normalize_histogram_by_hue(queue_depth_df, hue_col) - if sns and not queue_depth_norm.is_empty(): - sns.barplot( - data=queue_depth_norm.to_pandas(), - x="bucket_le", - y="per_bucket_count", - hue=hue_col, - ax=ax, - ) - ax.set_ylabel("RPC Queue Depth (count)") - ax.set_xlabel("") - - # Plot 3: Rollout Duration - ax = axs[3] - rollout_df = histograms_df.filter(pl.col("metric") == "rollout_duration_seconds") - if not rollout_df.is_empty(): - # Limit data to last non-zero bucket (filter before plotting since x is categorical) - max_nonzero = ( - rollout_df.filter(pl.col("per_bucket_count") > 0) - .select(pl.col("bucket_le_num").max()) - .item() - ) - if max_nonzero is not None and math.isfinite(max_nonzero): - rollout_df = rollout_df.filter(pl.col("bucket_le_num") <= max_nonzero) - - sns.barplot( - data=rollout_df.to_pandas(), - x="bucket_le", - y="per_bucket_count", - ax=ax, - errorbar=None, - color="grey", - ) - - # Show at most max_labels tick labels (categorical axis uses bar indices 0, 1, 2, ...) - num_bars = rollout_df.height - max_labels = 10 - if num_bars > max_labels: - every_nth_label = max(1, num_bars // max_labels) - for idx, label in enumerate(ax.xaxis.get_ticklabels()): - if (idx + 1) % every_nth_label != 0: - label.set_visible(False) - ax.set_ylabel("Rollout Duration (s)") - ax.set_xlabel("") - # Format x-axis ticks as integers - ax.xaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"{int(x)}")) - - # Plot 4: Step Duration - ax = axs[4] - step_df = histograms_df.filter(pl.col("metric") == "step_duration_seconds") - if sns and not step_df.is_empty(): - sns.barplot( - data=step_df.to_pandas(), - x="bucket_le", - y="per_bucket_count", - ax=ax, - color="grey", - ) - ax.set_ylabel("Step Duration (s)") - ax.set_xlabel("") + ax.set_xticks( + tick_indices, + [ + datetime.fromtimestamp(timestamps[index]).strftime("%H:%M") + for index in tick_indices + ], + ) + ax.set_yticks(range(len(boundaries)), [f"{boundary:g}" for boundary in boundaries]) + ax.set_ylabel("seconds") + ax.set_title(panel["title"]) - # Plot 5: Service configuration summary - _plot_service_config(axs[5], metrics_path) - # Plot 6: CPU boxplots - _plot_cpu_boxplots(axs[6], gauges_df) +def _plot_histogram( + ax: plt.Axes, + panel: dict[str, Any], + results: list[tuple[dict[str, Any], list[dict[str, Any]]]], +) -> None: + histograms = [] + for target, series_list in results: + for series in series_list: + _, values = _series_points(series) + if values: + histograms.append( + ( + _legend(target.get("legendFormat", ""), series["metric"]), + values, + ) + ) + if not histograms: + ax.text(0.5, 0.5, "No data", ha="center", va="center", transform=ax.transAxes) + ax.set_title(panel["title"]) + return - # Plot 7-8: GPU boxplots - _plot_gpu_boxplots(axs[7], axs[8], gauges_df) + bins = np.histogram_bin_edges( + [value for _, values in histograms for value in values], bins="auto" + ) + for label, values in histograms: + ax.hist(values, bins=bins, alpha=0.5, label=label) - # Clear x labels and rotate ticks - for ax in axs: - ax.set_xlabel("") + ax.legend(fontsize=7) + ax.set_ylabel("frequency") + ax.set_title(panel["title"]) - for label in ax.get_xticklabels(): - label.set_rotation(45) - label.set_ha("right") - run_name = run_name or "Unknown Run" - # Add title with summary stats - fig.suptitle( - ( - f"Run: {run_name}" - f" - Async worker idle percentage: {idle_percentage:.2%}, " - f"Sim seconds per rollout: {sim_seconds_per_rollout:.2f}" - ), - fontsize=16, +def generate_metrics_plot( + *, + prometheus_url: str, + output_path: Path, +) -> Path: + """Generate a time-based plot from the dashboard's summary queries.""" + output_path.parent.mkdir(parents=True, exist_ok=True) + metadata = _load_run_metadata(output_path.parent) + start = datetime.strptime( + str(metadata["run_time"]), "%Y-%m-%d %H:%M:%S" + ).timestamp() + end = time.time() + range_seconds = max(1, math.ceil(end - start)) + step = f"{max(5, math.ceil(range_seconds / 1200))}s" + + client = PrometheusClient(prometheus_url) + panels = _load_summary_panels() + rows = math.ceil(len(panels) / 3) + fig, axes = plt.subplots( + rows, + 3, + figsize=(24, 4 * rows), + constrained_layout=True, + squeeze=False, ) - plt.subplots_adjust(top=0.93) - - # Save figure - plt.savefig(output_path, dpi=150, bbox_inches="tight") - plt.close() + flat_axes = axes.flatten() - logger.info("Metrics plot saved to: %s", output_path) + try: + for ax, panel in zip(flat_axes, panels, strict=False): + results = [] + for target in panel["targets"]: + query = _resolve_query( + target["expr"], + run_uuid=metadata["run_uuid"], + run_name=metadata["run_name"], + range_seconds=range_seconds, + ) + series = client.query_range( + query, + start=start, + end=end, + step=step, + ) + results.append((target, series)) + if panel["type"] == "timeseries": + _plot_timeseries(ax, panel, results) + elif panel["type"] == "heatmap": + _plot_heatmap(ax, panel, results) + else: + _plot_histogram(ax, panel, results) + + for ax in flat_axes[len(panels) :]: + ax.axis("off") + for ax in flat_axes: + ax.tick_params(axis="x", rotation=30) + + start_text = datetime.fromtimestamp(start).strftime("%Y-%m-%d %H:%M:%S") + end_text = datetime.fromtimestamp(end).strftime("%Y-%m-%d %H:%M:%S") + fig.suptitle(f"{metadata['run_name']}\n{start_text} – {end_text}", fontsize=14) + fig.savefig(output_path, dpi=150) + finally: + plt.close(fig) + + logger.info("Generated telemetry metrics plot: %s", output_path) return output_path diff --git a/src/runtime/alpasim_runtime/telemetry/resources.py b/src/runtime/alpasim_runtime/telemetry/resources.py deleted file mode 100644 index 809069c7..00000000 --- a/src/runtime/alpasim_runtime/telemetry/resources.py +++ /dev/null @@ -1,315 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# Copyright (c) 2025-2026 NVIDIA Corporation - -""" -Background resource sampling with summary statistics. - -Uses a class-based design for proper encapsulation and lifecycle management. -Samples CPU (high-utilization processes) and GPU utilization every interval, -then exports min/max/mean/p50/p95/p99 as Prometheus gauges at shutdown. -""" - -from __future__ import annotations - -import asyncio -import logging -from collections import defaultdict -from dataclasses import dataclass, field -from pathlib import Path - -import numpy as np -import psutil -import pynvml -from prometheus_client import CollectorRegistry, Gauge - -logger = logging.getLogger(__name__) - -# Threshold: once a process exceeds this CPU utilization threshold, -# it becomes "tracked" for the rest of the run. -# This is to automatically track high-utilization processes without manually -# having to configure the jobs (which ppl might forget if they add new ones). -TRACK_THRESHOLD_PCT = 50.0 - -# Processes to ignore (e.g., nvidia-smi appears due to pynvml queries) -IGNORED_PROCESSES = {"nvidia-smi"} - - -# How often to sample resources in seconds -SAMPLE_INTERVAL_DEFAULT = 5.0 - - -@dataclass -class GPUMetrics: - """Structured GPU metric samples.""" - - gpu_id: int - utilization: list[float] = field(default_factory=list) - memory_used_bytes: list[float] = field(default_factory=list) - memory_total_bytes: float | None = None # Constant per GPU, set once - - -class ResourceSampler: - """ - Encapsulated resource sampler with proper lifecycle management. - - Usage: - sampler = ResourceSampler() - await sampler.start(interval=1.0) - # ... run simulation ... - await sampler.stop() - sampler.export_to(registry) - """ - - def __init__(self) -> None: - self._task: asyncio.Task[None] | None = None - self._nvml_initialized: bool = False - - # Process tracking: name -> list of CPU% samples - # Once a process exceeds TRACK_THRESHOLD_PCT, it's tracked for the rest of the run - self._process_samples: dict[str, list[float]] = defaultdict(list) - - # Persistent process objects for accurate CPU measurement - # pid -> (psutil.Process, name) - # We need to keep the same Process objects across samples because - # cpu_percent(interval=None) requires the same object to compute deltas - self._process_cache: dict[int, tuple[psutil.Process, str]] = {} - - # GPU tracking: gpu_id -> GPUMetrics - self._gpu_metrics: dict[int, GPUMetrics] = {} - - def _get_process_name(self, proc: psutil.Process) -> str: - """Extract a readable process name from a process object.""" - try: - cmdline = proc.cmdline() - except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): - cmdline = [] - - if not cmdline: - try: - return proc.name() - except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): - return f"pid_{proc.pid}" - - exe = Path(cmdline[0]).name - # For python scripts, use the script/module name - if exe in ("python", "python3") and len(cmdline) > 1: - # Handle "python -m module_name" invocation - if cmdline[1] == "-m" and len(cmdline) > 2: - # Keep full module path (e.g., "alpasim_runtime.worker.main") - return cmdline[2] - return Path(cmdline[1]).stem - return exe - - def _sample_processes(self) -> None: - """Sample CPU for tracked processes + discover new high-utilization ones.""" - # Get current PIDs to detect terminated processes - current_pids = set() - - for proc in psutil.process_iter(): - try: - pid = proc.pid - current_pids.add(pid) - - # Check if we have a cached process object for this PID - if pid in self._process_cache: - cached_proc, name = self._process_cache[pid] - try: - cpu = cached_proc.cpu_percent(interval=None) - except ( - psutil.NoSuchProcess, - psutil.AccessDenied, - psutil.ZombieProcess, - ): - # Process died, remove from cache - del self._process_cache[pid] - continue - - else: - # New process - create a fresh Process object and cache it - # Use the proc from iteration to get a stable object - name = self._get_process_name(proc) - # Prime it (first call returns 0) - try: - proc.cpu_percent(interval=None) - except ( - psutil.NoSuchProcess, - psutil.AccessDenied, - psutil.ZombieProcess, - ): - continue - # Cache for future samples (first cpu_percent call is priming only) - self._process_cache[pid] = (proc, name) - continue # Skip this sample, wait for next iteration - - if cpu is None or name in IGNORED_PROCESSES: - continue - - # Record sample if process exceeds threshold or is already tracked - if cpu >= TRACK_THRESHOLD_PCT or name in self._process_samples: - self._process_samples[name].append(cpu) - - except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): - pass - - # Clean up cache for terminated processes - terminated_pids = set(self._process_cache.keys()) - current_pids - for pid in terminated_pids: - del self._process_cache[pid] - - def _sample_gpus(self) -> None: - """Sample GPU utilization and memory.""" - if not self._nvml_initialized: - return - - try: - for i in range(pynvml.nvmlDeviceGetCount()): - handle = pynvml.nvmlDeviceGetHandleByIndex(i) - util = pynvml.nvmlDeviceGetUtilizationRates(handle) - mem = pynvml.nvmlDeviceGetMemoryInfo(handle) - - if i not in self._gpu_metrics: - self._gpu_metrics[i] = GPUMetrics(gpu_id=i) - - self._gpu_metrics[i].utilization.append(util.gpu) - self._gpu_metrics[i].memory_used_bytes.append(mem.used) - # Total memory is constant, only set once - if self._gpu_metrics[i].memory_total_bytes is None: - self._gpu_metrics[i].memory_total_bytes = float(mem.total) - except pynvml.NVMLError: - pass - - async def _sample_loop(self, interval: float) -> None: - """Background task that samples resources at regular intervals.""" - # Initial sample to populate the process cache and prime cpu_percent() - # The first sample for each process won't record data (priming phase) - self._sample_processes() - await asyncio.sleep(interval) - - while True: - self._sample_processes() - self._sample_gpus() - await asyncio.sleep(interval) - - async def start(self, interval: float = SAMPLE_INTERVAL_DEFAULT) -> None: - """Start background resource sampling.""" - # Initialize NVML if available - try: - pynvml.nvmlInit() - gpu_count = pynvml.nvmlDeviceGetCount() - self._nvml_initialized = True - logger.info(f"GPU monitoring initialized: {gpu_count} GPU(s) detected") - except pynvml.NVMLError as e: - logger.warning(f"Failed to initialize NVML for GPU monitoring: {e}") - - self._task = asyncio.create_task(self._sample_loop(interval)) - - async def stop(self) -> None: - """Stop background sampling.""" - if self._task: - self._task.cancel() - try: - await self._task - except asyncio.CancelledError: - pass - self._task = None - - # Shutdown NVML - if self._nvml_initialized: - try: - pynvml.nvmlShutdown() - except pynvml.NVMLError: - pass - self._nvml_initialized = False - - @staticmethod - def _compute_summary_stats(samples: list[float]) -> dict[str, float] | None: - """Compute summary statistics. Returns None if samples is empty.""" - if not samples: - return None - arr = np.array(samples) - return { - "min": float(np.min(arr)), - "max": float(np.max(arr)), - "mean": float(np.mean(arr)), - "p05": float(np.percentile(arr, 5)), - "p25": float(np.percentile(arr, 25)), - "p50": float(np.percentile(arr, 50)), - "p75": float(np.percentile(arr, 75)), - "p95": float(np.percentile(arr, 95)), - "p99": float(np.percentile(arr, 99)), - } - - def export_to(self, registry: CollectorRegistry) -> None: - """Export summary statistics as Prometheus gauges to the given registry.""" - # CPU gauge: single gauge with name + stat labels - if self._process_samples: - cpu_gauge = Gauge( - "process_cpu_utilization", - "CPU utilization summary statistics", - ["name", "stat"], - registry=registry, - ) - - for proc_name, samples in self._process_samples.items(): - stats = self._compute_summary_stats(samples) - if stats is None: - continue - for stat_name, stat_value in stats.items(): - cpu_gauge.labels(name=proc_name, stat=stat_name).set(stat_value) - - logger.debug( - f"Exported CPU metrics for {len(self._process_samples)} processes" - ) - else: - logger.debug("No CPU metrics to export") - - # GPU gauges: utilization and memory with gpu + stat labels - if self._gpu_metrics: - logger.info(f"Exporting GPU metrics for {len(self._gpu_metrics)} GPU(s)") - gpu_util_gauge = Gauge( - "gpu_utilization", - "GPU utilization summary statistics", - ["gpu", "stat"], - registry=registry, - ) - gpu_mem_gauge = Gauge( - "gpu_memory_used_bytes", - "GPU memory usage summary statistics", - ["gpu", "stat"], - registry=registry, - ) - gpu_mem_total_gauge = Gauge( - "gpu_memory_total_bytes", - "Total GPU memory available", - ["gpu"], - registry=registry, - ) - - for gpu_id, metrics in self._gpu_metrics.items(): - gpu_label = str(gpu_id) - - util_stats = self._compute_summary_stats(metrics.utilization) - if util_stats: - for stat_name, stat_value in util_stats.items(): - gpu_util_gauge.labels(gpu=gpu_label, stat=stat_name).set( - stat_value - ) - - mem_stats = self._compute_summary_stats(metrics.memory_used_bytes) - if mem_stats: - for stat_name, stat_value in mem_stats.items(): - gpu_mem_gauge.labels(gpu=gpu_label, stat=stat_name).set( - stat_value - ) - - if metrics.memory_total_bytes is not None: - gpu_mem_total_gauge.labels(gpu=gpu_label).set( - metrics.memory_total_bytes - ) - else: - if self._nvml_initialized: - logger.warning( - "No GPU samples collected despite NVML being initialized" - ) - else: - logger.debug("No GPU metrics to export (NVML not initialized)") diff --git a/src/runtime/alpasim_runtime/telemetry/rpc_wrapper.py b/src/runtime/alpasim_runtime/telemetry/rpc_wrapper.py index 5011fd7a..a44d33dc 100644 --- a/src/runtime/alpasim_runtime/telemetry/rpc_wrapper.py +++ b/src/runtime/alpasim_runtime/telemetry/rpc_wrapper.py @@ -179,16 +179,23 @@ def on_done(_: Any) -> None: if ctx is not None: tag = get_telemetry_tag() - ctx.rpc_queue_depth.labels(service=service_type, tag=tag).observe( - queue_depth_at_start - ) + worker_id = str(ctx.worker_id) + ctx.rpc_queue_depth_latest.labels( + service=service_type, tag=tag, worker_id=worker_id + ).set(queue_depth_at_start) ctx.rpc_duration.labels( - service=service_type, method=method_name, tag=tag + service=service_type, + method=method_name, + tag=tag, + worker_id=worker_id, ).observe(duration) # Only record blocking time if callback was successfully registered if t_done is not None: blocking = max(0, t_resume - t_done) ctx.rpc_blocking.labels( - service=service_type, method=method_name, tag=tag + service=service_type, + method=method_name, + tag=tag, + worker_id=worker_id, ).observe(blocking) diff --git a/src/runtime/alpasim_runtime/telemetry/telemetry_context.py b/src/runtime/alpasim_runtime/telemetry/telemetry_context.py index 29afc9cb..15108aa2 100644 --- a/src/runtime/alpasim_runtime/telemetry/telemetry_context.py +++ b/src/runtime/alpasim_runtime/telemetry/telemetry_context.py @@ -7,28 +7,33 @@ from __future__ import annotations +import asyncio import contextlib import logging -import os from contextvars import ContextVar from dataclasses import dataclass, field -from pathlib import Path +from time import perf_counter from types import TracebackType from typing import Generator, Type from alpasim_runtime.event_loop_idle_profiler import get_event_loop_idle_stats from alpasim_runtime.gc_pressure_profiler import get_gc_pressure_stats -from prometheus_client import CollectorRegistry, Gauge, Histogram, write_to_textfile - -from .resources import ResourceSampler +from prometheus_client import ( + CollectorRegistry, + Counter, + Gauge, + Histogram, + start_http_server, +) logger = logging.getLogger(__name__) +WORKER_LABELS = ("worker_id",) + # Histogram bucket definitions (centralized) HISTOGRAM_BUCKETS = { - "rpc_duration": (0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0, 5.0, 10.0), + "rpc_duration": (0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0, 20.0), "rpc_blocking": (0.0001, 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0), - "rpc_queue_depth": [0, 1, 2, 3, 5, 7, 10, 15, 20, 25, 30, 35, 50], "rollout_duration": list(range(1, 300)), "step_duration": (0.1, 0.5, 1, 2, 5, 10, 30), } @@ -41,34 +46,27 @@ class TelemetryContext: Use as a context manager for automatic setup/shutdown: - async with TelemetryContext(output_dir, worker_id) as ctx: + async with TelemetryContext(worker_id) as ctx: # ctx.metrics available here await run_simulation() - # Metrics automatically written on exit - - Resource sampling: Make sure to only sample resources - (i.e. sample_resources=True) for one process in the simulation. """ - output_dir: str worker_id: int = 0 - sample_resources: bool = False - resource_sample_interval: float = 1.0 + bind_host: str = "0.0.0.0" + port: int | None = None + refresh_interval_s: float = 1.0 # Metrics (initialized in __post_init__) registry: CollectorRegistry = field(init=False) rpc_duration: Histogram = field(init=False) rpc_blocking: Histogram = field(init=False) - rpc_queue_depth: Histogram = field(init=False) + rpc_queue_depth_latest: Gauge = field(init=False) rollout_duration: Histogram = field(init=False) step_duration: Histogram = field(init=False) - # Note that because we don't have an active Prometheus server, - # Gauges are only set once at the end of the simulation. - # Simulation summary gauges - simulation_total_seconds: Gauge = field(init=False) - simulation_rollout_count: Gauge = field(init=False) - simulation_seconds_per_rollout: Gauge = field(init=False) + # Simulation summary metrics + simulation_elapsed_seconds: Gauge = field(init=False) + simulation_rollouts_completed: Counter = field(init=False) # Event loop gauges event_loop_idle_seconds: Gauge = field(init=False) @@ -80,123 +78,164 @@ class TelemetryContext: gc_max_duration_seconds: Gauge = field(init=False) gc_collection_count: Gauge = field(init=False) - # Resource sampler (owned by context) - _resource_sampler: ResourceSampler | None = field(init=False, default=None) + _httpd: object | None = field(init=False, default=None) + _http_thread: object | None = field(init=False, default=None) + _refresh_task: asyncio.Task[None] | None = field(init=False, default=None) + _simulation_started_at: float | None = field(init=False, default=None) def __post_init__(self) -> None: - os.makedirs(self.output_dir, exist_ok=True) self.registry = CollectorRegistry() - + worker_labels = {"worker_id": str(self.worker_id)} # RPC metrics (tag label allows filtering warmup/special operations) self.rpc_duration = Histogram( - "rpc_duration_seconds", + "alpasim_rpc_duration_seconds", "RPC call duration", - ["service", "method", "tag"], + ["service", "method", "tag", *WORKER_LABELS], buckets=HISTOGRAM_BUCKETS["rpc_duration"], registry=self.registry, ) self.rpc_blocking = Histogram( - "rpc_blocking_seconds", + "alpasim_rpc_blocking_seconds", "Time between gRPC I/O completion and coroutine resumption", - ["service", "method", "tag"], + ["service", "method", "tag", *WORKER_LABELS], buckets=HISTOGRAM_BUCKETS["rpc_blocking"], registry=self.registry, ) - self.rpc_queue_depth = Histogram( - "rpc_queue_depth_at_start", - "Queue depth when RPC was initiated", - ["service", "tag"], - buckets=HISTOGRAM_BUCKETS["rpc_queue_depth"], + self.rpc_queue_depth_latest = Gauge( + "alpasim_rpc_queue_depth_at_start_latest", + "Latest observed queue depth when an RPC was initiated", + ["service", "tag", *WORKER_LABELS], registry=self.registry, ) # Simulation timing self.rollout_duration = Histogram( - "rollout_duration_seconds", + "alpasim_rollout_duration_seconds", "Total rollout execution time", - [], + WORKER_LABELS, buckets=HISTOGRAM_BUCKETS["rollout_duration"], registry=self.registry, - ) + ).labels(**worker_labels) self.step_duration = Histogram( - "step_duration_seconds", + "alpasim_step_duration_seconds", "Per-step execution time", - [], + WORKER_LABELS, buckets=HISTOGRAM_BUCKETS["step_duration"], registry=self.registry, - ) + ).labels(**worker_labels) - # Pre-register simulation summary gauges - self.simulation_total_seconds = Gauge( - "simulation_total_seconds", - "Total simulation time", + # Pre-register simulation summary metrics + self.simulation_elapsed_seconds = Gauge( + "alpasim_simulation_elapsed_seconds", + "Simulation worker elapsed time sampled when rollouts complete", registry=self.registry, - ) - self.simulation_rollout_count = Gauge( - "simulation_rollout_count", - "Number of rollouts", - registry=self.registry, - ) - self.simulation_seconds_per_rollout = Gauge( - "simulation_seconds_per_rollout", - "Average time per rollout", + labelnames=WORKER_LABELS, + ).labels(**worker_labels) + self.simulation_rollouts_completed = Counter( + "alpasim_simulation_rollouts_completed", + "Number of completed rollouts", registry=self.registry, - ) + labelnames=WORKER_LABELS, + ).labels(**worker_labels) # Pre-register event loop gauges self.event_loop_idle_seconds = Gauge( - "event_loop_idle_seconds_total", + "alpasim_event_loop_idle_seconds_total", "Total event loop idle time (blocking waits for I/O)", registry=self.registry, - ) + labelnames=WORKER_LABELS, + ).labels(**worker_labels) self.event_loop_poll_seconds = Gauge( - "event_loop_poll_seconds_total", + "alpasim_event_loop_poll_seconds_total", "Total event loop poll time (non-blocking I/O checks)", registry=self.registry, - ) + labelnames=WORKER_LABELS, + ).labels(**worker_labels) self.event_loop_work_seconds = Gauge( - "event_loop_work_seconds_total", + "alpasim_event_loop_work_seconds_total", "Total event loop work time (executing Python code)", registry=self.registry, - ) + labelnames=WORKER_LABELS, + ).labels(**worker_labels) # GC pressure gauges self.gc_total_duration_seconds = Gauge( - "gc_total_duration_seconds", + "alpasim_gc_total_duration_seconds", "Total time spent in garbage collection", registry=self.registry, - ) + labelnames=WORKER_LABELS, + ).labels(**worker_labels) self.gc_max_duration_seconds = Gauge( - "gc_max_duration_seconds", + "alpasim_gc_max_duration_seconds", "Longest single garbage collection pause", registry=self.registry, - ) + labelnames=WORKER_LABELS, + ).labels(**worker_labels) self.gc_collection_count = Gauge( - "gc_collection_count_total", + "alpasim_gc_collection_count_total", "Total number of GC collections", registry=self.registry, - ) + labelnames=WORKER_LABELS, + ).labels(**worker_labels) + + def record_rollout_complete(self) -> None: + """Record one completed rollout in the live simulation summary.""" + self.simulation_rollouts_completed.inc() + if self._simulation_started_at is not None: + self.simulation_elapsed_seconds.set( + perf_counter() - self._simulation_started_at + ) + + def refresh_gauges(self) -> None: + """Refresh live gauge snapshots for Prometheus scrapes.""" + self._refresh_event_loop_gauges() + self._refresh_gc_pressure_gauges() + + def _refresh_event_loop_gauges(self) -> None: + idle_stats = get_event_loop_idle_stats() + self.event_loop_idle_seconds.set(idle_stats["idle_seconds"]) + self.event_loop_poll_seconds.set(idle_stats["poll_seconds"]) + self.event_loop_work_seconds.set(idle_stats["work_seconds"]) - def record_simulation_summary( - self, total_seconds: float, rollout_count: int - ) -> None: - """Record simulation summary metrics (called once at end of run).""" - self.simulation_total_seconds.set(total_seconds) - self.simulation_rollout_count.set(rollout_count) - if rollout_count > 0: - self.simulation_seconds_per_rollout.set(total_seconds / rollout_count) - - def shutdown(self) -> None: - """Dump all metrics to file.""" - prom_path = Path(self.output_dir) / f"metrics_worker_{self.worker_id}.prom" - write_to_textfile(str(prom_path), self.registry) - logger.info(f"Metrics written to {prom_path}") + def _refresh_gc_pressure_gauges(self) -> None: + gc_stats = get_gc_pressure_stats() + self.gc_total_duration_seconds.set(gc_stats["total_duration_s"]) + self.gc_max_duration_seconds.set(gc_stats["max_duration_s"]) + self.gc_collection_count.set(gc_stats["collection_count"]) + + async def _refresh_gauges_periodically(self) -> None: + while True: + self.refresh_gauges() + await asyncio.sleep(self.refresh_interval_s) async def __aenter__(self) -> "TelemetryContext": + if self.port is None: + raise ValueError(f"Telemetry port missing for worker {self.worker_id}") + try: + self._httpd, self._http_thread = start_http_server( + self.port, + addr=self.bind_host, + registry=self.registry, + ) + logger.info( + "Worker %d metrics endpoint listening on %s:%d", + self.worker_id, + self.bind_host, + self.port, + ) + self._simulation_started_at = perf_counter() + self.refresh_gauges() + self._refresh_task = asyncio.create_task( + self._refresh_gauges_periodically() + ) + except BaseException: + if self._httpd is not None: + self._httpd.shutdown() + self._httpd.server_close() + self._httpd = None + self._http_thread = None + raise _current_context.set(self) - if self.sample_resources: - self._resource_sampler = ResourceSampler() - await self._resource_sampler.start(self.resource_sample_interval) return self async def __aexit__( @@ -205,24 +244,21 @@ async def __aexit__( exc_val: BaseException | None, exc_tb: TracebackType | None, ) -> None: - if self._resource_sampler: - await self._resource_sampler.stop() - self._resource_sampler.export_to(self.registry) - - # Record event loop stats - idle_stats = get_event_loop_idle_stats() - self.event_loop_idle_seconds.set(idle_stats["idle_seconds"]) - self.event_loop_poll_seconds.set(idle_stats["poll_seconds"]) - self.event_loop_work_seconds.set(idle_stats["work_seconds"]) - - # Record GC pressure stats - gc_stats = get_gc_pressure_stats() - self.gc_total_duration_seconds.set(gc_stats["total_duration_s"]) - self.gc_max_duration_seconds.set(gc_stats["max_duration_s"]) - self.gc_collection_count.set(gc_stats["collection_count"]) - - self.shutdown() - _current_context.set(None) + if self._refresh_task is not None: + self._refresh_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._refresh_task + self._refresh_task = None + + try: + self.refresh_gauges() + finally: + if self._httpd is not None: + self._httpd.shutdown() + self._httpd.server_close() + self._httpd = None + self._http_thread = None + _current_context.set(None) # Task-local context using ContextVar (async-safe) diff --git a/src/runtime/alpasim_runtime/telemetry/utils.py b/src/runtime/alpasim_runtime/telemetry/utils.py deleted file mode 100644 index 7536c1a3..00000000 --- a/src/runtime/alpasim_runtime/telemetry/utils.py +++ /dev/null @@ -1,137 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# Copyright (c) 2025-2026 NVIDIA Corporation - -""" -Telemetry utility functions. -""" - -from __future__ import annotations - -import logging -import os -import re - -logger = logging.getLogger(__name__) - - -def merge_metrics_files(metrics_dir: str) -> None: - """Combines individual worker metric files into single aggregate file. - - Groups all samples by metric family to ensure valid Prometheus text format. - The Prometheus parser expects all samples of a metric to appear contiguously - after the HELP/TYPE declarations. - - Note: This method is vibe-coded and not thoroughly checked, but seems to work. - """ - prom_files = sorted( - [ - f - for f in os.listdir(metrics_dir) - if f.startswith("metrics_worker_") - and f.endswith(".prom") - and f != "metrics.prom" - ] - ) - if not prom_files: - return - - merged_prom = os.path.join(metrics_dir, "metrics.prom") - - # Regex to match metric lines: name{labels} value [timestamp] - # Group 1: metric name (including _bucket, _sum, _count suffixes) - # Group 2: existing labels (optional) - # Group 3: value + timestamp - metric_pattern = re.compile(r"^([a-zA-Z0-9_:]+)(?:\{([^}]*)\})?( .+)$") - - # Extract base metric family name (strip _bucket, _sum, _count suffixes for histograms) - # Note: _created and _total are NOT stripped - they are separate metric families - def get_metric_family(name: str) -> str: - for suffix in ("_bucket", "_sum", "_count"): - if name.endswith(suffix): - return name[: -len(suffix)] - return name - - # Collect all data grouped by metric family - # Structure: {family_name: {"help": str, "type": str, "samples": [str]}} - metrics_by_family: dict[str, dict] = {} - family_order: list[str] = [] # Preserve order of first appearance - - for fname in prom_files: - # Determine worker ID from filename (metrics_worker_N.prom -> N) - worker_id = fname.replace("metrics_worker_", "").replace(".prom", "") - worker_label = f'worker_id="{worker_id}"' - current_family: str | None = None - - with open(os.path.join(metrics_dir, fname), "r") as infile: - for line in infile: - line = line.strip() - if not line: - continue - - if line.startswith("# HELP "): - # Extract family name from HELP line - parts = line.split(" ", 3) - if len(parts) >= 3: - current_family = parts[2] - if current_family not in metrics_by_family: - metrics_by_family[current_family] = { - "help": line, - "type": None, - "samples": [], - } - family_order.append(current_family) - continue - - if line.startswith("# TYPE "): - # Extract family name and type from TYPE line - parts = line.split(" ", 4) - if len(parts) >= 4: - current_family = parts[2] - if current_family not in metrics_by_family: - metrics_by_family[current_family] = { - "help": None, - "type": line, - "samples": [], - } - family_order.append(current_family) - else: - metrics_by_family[current_family]["type"] = line - continue - - # Regular metric line - match = metric_pattern.match(line) - if match: - name, labels, rest = match.groups() - family = get_metric_family(name) - - # Add worker_id label - if labels: - new_labels = f"{labels},{worker_label}" - else: - new_labels = worker_label - - sample_line = f"{name}{{{new_labels}}}{rest}" - - if family not in metrics_by_family: - # Orphan metric without HELP/TYPE (shouldn't happen normally) - metrics_by_family[family] = { - "help": None, - "type": None, - "samples": [], - } - family_order.append(family) - - metrics_by_family[family]["samples"].append(sample_line) - - # Write merged file with metrics grouped by family - with open(merged_prom, "w") as outfile: - for family in family_order: - data = metrics_by_family[family] - if data["help"]: - outfile.write(data["help"] + "\n") - if data["type"]: - outfile.write(data["type"] + "\n") - for sample in data["samples"]: - outfile.write(sample + "\n") - - logger.info(f"Merged {len(prom_files)} Prometheus files into {merged_prom}") diff --git a/src/runtime/alpasim_runtime/validation.py b/src/runtime/alpasim_runtime/validation.py index 08998e4c..26e33b7f 100644 --- a/src/runtime/alpasim_runtime/validation.py +++ b/src/runtime/alpasim_runtime/validation.py @@ -296,7 +296,6 @@ async def validate_scenarios(config: SimulatorConfig) -> None: address, config.user.scenes, timeout_s=config.user.endpoints.startup_timeout_s, - use_metadata=(svc_name == "trafficsim"), ), label=f"Scenario validation probe for {svc_name} at {address}", ) @@ -315,35 +314,18 @@ async def _probe_scenario_compatibility( address: str, scenes: list[SceneConfig], timeout_s: int = 30, - use_metadata: bool = False, ) -> list[str]: - """Probe a service address to validate scenario compatibility without creating pools. - - Args: - svc_name: Name of the service being probed (for logging). - use_metadata: If True, use get_metadata().supported_map_ids instead of - get_available_scenes().scene_ids (for trafficsim compatibility). - """ + """Probe a service address to validate scenario compatibility without creating pools.""" incompatibilities = [] logger.info("Validating scenarios on %s at %s...", svc_name, address) channel = grpc.aio.insecure_channel(address) try: stub = stub_class(channel) - if use_metadata: - # trafficsim uses get_metadata with supported_map_ids - response = await stub.get_metadata( - Empty(), wait_for_ready=True, timeout=timeout_s - ) - # trafficsim returns map_ids without the clipgt- prefix, so we add it - available_scenes = set( - f"clipgt-{map_id}" for map_id in response.supported_map_ids - ) - else: - response = await stub.get_available_scenes( - Empty(), wait_for_ready=True, timeout=timeout_s - ) - available_scenes = set(response.scene_ids) + response = await stub.get_available_scenes( + Empty(), wait_for_ready=True, timeout=timeout_s + ) + available_scenes = set(response.scene_ids) for scene in scenes: if scene.scene_id not in available_scenes and "*" not in available_scenes: diff --git a/src/runtime/alpasim_runtime/worker/ipc.py b/src/runtime/alpasim_runtime/worker/ipc.py index 82a4669f..6b9fcfdc 100644 --- a/src/runtime/alpasim_runtime/worker/ipc.py +++ b/src/runtime/alpasim_runtime/worker/ipc.py @@ -112,3 +112,4 @@ class WorkerArgs: parent_pid: int | None = None # Shared RPC tracking for global queue depth metrics across processes shared_rpc_tracking: SharedRpcTracking | None = None + telemetry_port: int | None = None diff --git a/src/runtime/alpasim_runtime/worker/main.py b/src/runtime/alpasim_runtime/worker/main.py index 59b0991c..474fb649 100644 --- a/src/runtime/alpasim_runtime/worker/main.py +++ b/src/runtime/alpasim_runtime/worker/main.py @@ -21,7 +21,6 @@ import multiprocessing as mp import os import sys -import time import traceback from concurrent.futures import ProcessPoolExecutor from multiprocessing import Queue @@ -41,7 +40,10 @@ from alpasim_runtime.services.traffic_service import TrafficService from alpasim_runtime.services.video_model_service import VideoModelService from alpasim_runtime.telemetry.rpc_wrapper import set_shared_rpc_tracking -from alpasim_runtime.telemetry.telemetry_context import TelemetryContext +from alpasim_runtime.telemetry.telemetry_context import ( + TelemetryContext, + try_get_context, +) from alpasim_runtime.unbound_rollout import UnboundRollout from alpasim_runtime.worker.ipc import ( AssignedRolloutJob, @@ -288,6 +290,9 @@ def _poll_job() -> AssignedRolloutJob | _ShutdownSentinel | None: ) result_queue.put(result) rollout_count += 1 + telemetry_ctx = try_get_context() + if telemetry_ctx is not None: + telemetry_ctx.record_rollout_complete() # Spawn num_consumers consumer tasks -- each handles one job at a time try: @@ -325,7 +330,6 @@ async def worker_async_main(args: WorkerArgs) -> None: txt_logs_dir = os.path.join(args.log_dir, "txt-logs") rollouts_dir = os.path.join(args.log_dir, "rollouts") - telemetry_dir = os.path.join(args.log_dir, "telemetry") os.makedirs(txt_logs_dir, exist_ok=True) # Configure logging with worker_id in format. @@ -360,16 +364,12 @@ async def worker_async_main(args: WorkerArgs) -> None: camera_catalog = CameraCatalog(user_config.extra_cameras) - start_time = time.perf_counter() - - # TelemetryContext for telemetry collection. - # Worker 0 samples resources (CPU/GPU); other workers only collect RPC/rollout/step timing. + # TelemetryContext for live Prometheus scraping. async with TelemetryContext( - output_dir=telemetry_dir, worker_id=args.worker_id, - sample_resources=(args.worker_id == 0), - ) as ctx: - rollout_count = await run_worker_loop( + port=args.telemetry_port, + ): + await run_worker_loop( worker_id=args.worker_id, job_queue=args.job_queue, result_queue=args.result_queue, @@ -383,8 +383,4 @@ async def worker_async_main(args: WorkerArgs) -> None: parent_pid=args.parent_pid, ) - # Record simulation summary with actual measured values - total_time = time.perf_counter() - start_time - ctx.record_simulation_summary(total_time, rollout_count) - module_logger.info("Worker %d exiting", args.worker_id) diff --git a/src/runtime/alpasim_runtime/worker/runtime.py b/src/runtime/alpasim_runtime/worker/runtime.py index 5bf69648..647b4dac 100644 --- a/src/runtime/alpasim_runtime/worker/runtime.py +++ b/src/runtime/alpasim_runtime/worker/runtime.py @@ -140,6 +140,8 @@ def start_worker_runtime( For ``nr_workers>1``, spawns subprocess workers with shared RPC tracking. """ nr_workers = config.user.nr_workers + prometheus = config.user.prometheus + worker_ports = list(prometheus.worker_ports) job_queue: Queue = Queue() result_queue: Queue = Queue() @@ -155,6 +157,7 @@ def start_worker_runtime( eval_config=eval_config, version_ids=version_ids, parent_pid=None, + telemetry_port=worker_ports[0], ) runtime = WorkerRuntime( job_queue=job_queue, @@ -178,6 +181,7 @@ def start_worker_runtime( version_ids=version_ids, parent_pid=parent_pid, shared_rpc_tracking=shared_rpc_tracking, + telemetry_port=worker_ports[worker_id], ) for worker_id in range(nr_workers) ] diff --git a/src/runtime/pyproject.toml b/src/runtime/pyproject.toml index 29464bb0..a069d3fb 100644 --- a/src/runtime/pyproject.toml +++ b/src/runtime/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" # Trivial comment to trigger CI build-services (for test-slurm dependency). [project] name = "alpasim-runtime" -version = "1.118.0" +version = "1.123.0" description = "Runtime of the Alpamayo Sim" requires-python = ">=3.11,<3.13" authors = [{name = "Michal Tyszkiewicz", email = "mtyszkiewicz@nvidia.com"}] @@ -21,13 +21,10 @@ dependencies = [ "polars>=1.0.0", # Metrics data processing "pyarrow>=15.0.0", # Required for polars to_pandas() conversion "prometheus-client>=0.21.0", - "psutil>=5.9", - "nvidia-ml-py>=11.5.0", # GPU monitoring "protobuf>=4.25.3", "pyyaml>=6.0.2", "rich>=13.0.0", "scipy>=1.15.0", - "seaborn>=0.13.0", # Metrics plotting "csaps>=1.2.0", "trajdata-alpasim", "pandas<3", # pandas 3.x breaks trajdata diff --git a/src/runtime/tests/data/integration/generated-user-config-0.yaml b/src/runtime/tests/data/integration/generated-user-config-0.yaml index ea6df281..973582df 100644 --- a/src/runtime/tests/data/integration/generated-user-config-0.yaml +++ b/src/runtime/tests/data/integration/generated-user-config-0.yaml @@ -12,7 +12,6 @@ endpoints: skip: false renderer: n_concurrent_rollouts: 2 - sensorsim_cache_size: '4' trafficsim: n_concurrent_rollouts: 14 skip: false @@ -20,6 +19,9 @@ renderer: kind: sensorsim video_model_config: null nr_workers: 1 +prometheus: + url: http://127.0.0.1:9090 + worker_ports: [0] scenes: - scene_id: clipgt-c14c031a-8c17-4d08-aa4d-23c020a6871e simulation_config: diff --git a/src/runtime/tests/data/mock/user-config.yaml b/src/runtime/tests/data/mock/user-config.yaml index 37b87315..a5a61024 100644 --- a/src/runtime/tests/data/mock/user-config.yaml +++ b/src/runtime/tests/data/mock/user-config.yaml @@ -1,5 +1,9 @@ nr_workers: 1 +prometheus: + url: http://127.0.0.1:9090 + worker_ports: [0] + scene_provider: kind: usdz usdz: diff --git a/src/runtime/tests/test_address_pool.py b/src/runtime/tests/test_address_pool.py index b9afa7b5..0a69984e 100644 --- a/src/runtime/tests/test_address_pool.py +++ b/src/runtime/tests/test_address_pool.py @@ -147,6 +147,47 @@ def test_preserves_total_slots(self): assert pool.try_acquire() is not None assert pool.try_acquire() is None + def test_free_addresses(self): + """free_addresses should return addresses with available slots.""" + pool = AddressPool(["A", "B"], n_concurrent=1, skip=False) + assert pool.free_addresses() == {"A", "B"} + + slot = pool.try_acquire() + remaining = pool.free_addresses() + # One address consumed + assert len(remaining) == 1 + + pool.release(slot) + assert pool.free_addresses() == {"A", "B"} + + def test_try_acquire_for_address(self): + """Should acquire a specific address's slot.""" + pool = AddressPool(["A", "B"], n_concurrent=1, skip=False) + + slot = pool.try_acquire_for_address("B") + assert slot is not None + assert slot.address == "B" + + # B is now exhausted + assert pool.try_acquire_for_address("B") is None + # A still available + assert pool.try_acquire_for_address("A") is not None + + def test_try_acquire_for_address_skip(self): + """Skip pool should return skip slot for any address.""" + pool = AddressPool([], n_concurrent=0, skip=True) + slot = pool.try_acquire_for_address("anything") + assert slot is not None + assert slot.skip is True + + def test_all_addresses(self): + pool = AddressPool(["A", "B", "C"], n_concurrent=2, skip=False) + assert pool.all_addresses() == frozenset({"A", "B", "C"}) + + def test_all_addresses_skip(self): + pool = AddressPool(["A"], n_concurrent=2, skip=True) + assert pool.all_addresses() == frozenset() + class TestTryAcquireAll: """Tests for try_acquire_all function.""" @@ -208,6 +249,44 @@ def test_empty_pools(self): result = try_acquire_all({}) assert result == {} + def test_pre_acquired_renderer_slot(self): + """try_acquire_all with renderer_slot skips renderer pool.""" + pools = { + "driver": AddressPool(["D"], n_concurrent=2, skip=False), + "renderer": AddressPool(["GPU-0", "GPU-1"], n_concurrent=1, skip=False), + } + + # Pre-acquire a renderer slot (as the scheduler would) + r_slot = pools["renderer"].try_acquire() + assert r_slot is not None + + result = try_acquire_all(pools, renderer_slot=r_slot) + assert result is not None + assert result["renderer"] is r_slot + assert result["driver"].address == "D" + + def test_pre_acquired_slot_rolled_back_on_failure(self): + """If another pool fails, the pre-acquired renderer slot is released.""" + pools = { + "driver": AddressPool(["D"], n_concurrent=1, skip=False), + "renderer": AddressPool(["GPU-0"], n_concurrent=1, skip=False), + } + + # Exhaust driver + pools["driver"].try_acquire() + + # Pre-acquire renderer + r_slot = pools["renderer"].try_acquire() + assert r_slot is not None + + result = try_acquire_all(pools, renderer_slot=r_slot) + assert result is None + + # renderer slot should have been released back by rollback + recovered = pools["renderer"].try_acquire() + assert recovered is not None + assert recovered.address == "GPU-0" + class TestReleaseAll: """Tests for release_all function.""" diff --git a/src/runtime/tests/test_daemon_engine.py b/src/runtime/tests/test_daemon_engine.py index 78cf80d9..4f8f5e65 100644 --- a/src/runtime/tests/test_daemon_engine.py +++ b/src/runtime/tests/test_daemon_engine.py @@ -32,6 +32,8 @@ def _make_config() -> SimpleNamespace: smooth_trajectories=True, scenes=[SimpleNamespace(scene_id="clipgt-a")], endpoints=SimpleNamespace(startup_timeout_s=1), + scene_affine_dispatch=False, + cache_refresh_interval_s=5.0, ), network=SimpleNamespace(), ) @@ -146,19 +148,12 @@ async def test_engine_startup_gathers_versions_and_validates_scenes( monkeypatch: pytest.MonkeyPatch, ) -> None: config = _make_config() - version_ids = MagicMock() + version_ids = _version_ids() eval_config = MagicMock() worker_runtime = SimpleNamespace(stop=AsyncMock()) class _FakeScheduler: - def __init__( - self, - *, - pools, - runtime, - request_store=None, - ) -> None: - del pools, request_store + def __init__(self, *, runtime, **kwargs) -> None: assert runtime is worker_runtime async def shutdown(self, *, reason: str) -> None: @@ -176,7 +171,7 @@ async def _fake_build_runtime_context(*args, **kwargs): eval_config=eval_config, version_ids=version_ids, scene_loader=MagicMock(), - pools={"driver": MagicMock()}, + pools={"driver": AddressPool(["driver-a:50051"], 1, skip=False)}, max_in_flight=1, ) @@ -192,7 +187,6 @@ async def _fake_build_runtime_context(*args, **kwargs): "alpasim_runtime.daemon.engine.start_worker_runtime", _fake_start_worker_runtime ) monkeypatch.setattr("alpasim_runtime.daemon.engine.DaemonScheduler", _FakeScheduler) - engine = DaemonEngine( user_config="u.yaml", network_config="n.yaml", @@ -210,19 +204,12 @@ async def test_engine_startup_skips_config_scene_validation_when_disabled( monkeypatch: pytest.MonkeyPatch, ) -> None: config = _make_config() - version_ids = MagicMock() + version_ids = _version_ids() eval_config = MagicMock() worker_runtime = SimpleNamespace(stop=AsyncMock()) class _FakeScheduler: - def __init__( - self, - *, - pools, - runtime, - request_store=None, - ) -> None: - del pools, request_store + def __init__(self, *, runtime, **kwargs) -> None: assert runtime is worker_runtime async def shutdown(self, *, reason: str) -> None: @@ -240,7 +227,7 @@ async def _fake_build_runtime_context(*args, **kwargs): eval_config=eval_config, version_ids=version_ids, scene_loader=MagicMock(), - pools={"driver": MagicMock()}, + pools={"driver": AddressPool(["driver-a:50051"], 1, skip=False)}, max_in_flight=1, ) @@ -256,7 +243,6 @@ async def _fake_build_runtime_context(*args, **kwargs): "alpasim_runtime.daemon.engine.start_worker_runtime", _fake_start_worker_runtime ) monkeypatch.setattr("alpasim_runtime.daemon.engine.DaemonScheduler", _FakeScheduler) - engine = DaemonEngine( user_config="u.yaml", network_config="n.yaml", diff --git a/src/runtime/tests/test_daemon_main.py b/src/runtime/tests/test_daemon_main.py index 7b98c8d2..79427917 100644 --- a/src/runtime/tests/test_daemon_main.py +++ b/src/runtime/tests/test_daemon_main.py @@ -5,6 +5,7 @@ import asyncio from argparse import Namespace +from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock, Mock @@ -404,12 +405,12 @@ async def stop(self, grace: float) -> None: engine.shutdown.assert_awaited_once() -def _make_one_shot_args() -> Namespace: +def _make_one_shot_args(log_dir: str = "/tmp/log") -> Namespace: return Namespace( user_config="u.yaml", network_config="n.yaml", eval_config="e.yaml", - log_dir="/tmp/log", + log_dir=log_dir, array_job_dir=None, ) @@ -459,7 +460,10 @@ async def test_run_simulation_one_shot_uses_daemon_engine( monkeypatch: pytest.MonkeyPatch, ) -> None: fake_config = SimpleNamespace( - user=SimpleNamespace(nr_workers=1), + user=SimpleNamespace( + nr_workers=1, + prometheus=SimpleNamespace(url="http://prometheus-0:9090"), + ), ) fake_eval_config = SimpleNamespace(run_in_runtime=False, enabled=False) _patch_one_shot_inputs( @@ -493,11 +497,6 @@ async def test_run_simulation_one_shot_uses_daemon_engine( engine_cls, ) - merge_metrics_files = Mock() - monkeypatch.setattr( - "alpasim_runtime.simulate.__main__.merge_metrics_files", - merge_metrics_files, - ) generate_metrics_plot = Mock(return_value="/tmp/log/metrics_plot.png") monkeypatch.setattr( "alpasim_runtime.simulate.__main__.generate_metrics_plot", @@ -521,8 +520,10 @@ async def test_run_simulation_one_shot_uses_daemon_engine( ) fake_engine.startup.assert_awaited_once() fake_engine.shutdown.assert_awaited_once() - merge_metrics_files.assert_called_once_with("/tmp/log/telemetry") - generate_metrics_plot.assert_called_once() + generate_metrics_plot.assert_called_once_with( + prometheus_url="http://prometheus-0:9090", + output_path=Path("/tmp/log/metrics_plot.png"), + ) request = fake_engine.simulate.await_args.args[0] assert isinstance(request, runtime_pb2.SimulationRequest) @@ -532,12 +533,76 @@ async def test_run_simulation_one_shot_uses_daemon_engine( ] == [("clipgt-a", 2), ("clipgt-b", 1)] +@pytest.mark.asyncio +async def test_run_simulation_metrics_artifact_failure_is_best_effort( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake_config = SimpleNamespace( + user=SimpleNamespace( + nr_workers=1, + prometheus=SimpleNamespace(url="http://prometheus-0:9090"), + ), + ) + fake_eval_config = SimpleNamespace(run_in_runtime=True, enabled=True) + _patch_one_shot_inputs( + monkeypatch, + fake_config=fake_config, + fake_eval_config=fake_eval_config, + request=runtime_pb2.SimulationRequest( + rollout_specs=[ + runtime_pb2.RolloutSpec(scenario_id="clipgt-a", nr_rollouts=1), + ] + ), + ) + fake_engine = SimpleNamespace( + startup=AsyncMock(), + simulate=AsyncMock( + return_value=_make_simulation_return([("clipgt-a", True, None)]) + ), + shutdown=AsyncMock(), + ) + monkeypatch.setattr( + "alpasim_runtime.simulate.__main__.DaemonEngine", + Mock(return_value=fake_engine), + ) + generate_metrics_plot = Mock( + side_effect=RuntimeError("Prometheus query failed: bad result") + ) + monkeypatch.setattr( + "alpasim_runtime.simulate.__main__.generate_metrics_plot", + generate_metrics_plot, + ) + monkeypatch.setattr( + "alpasim_runtime.simulate.__main__.get_run_name", + Mock(return_value="run-name"), + ) + run_aggregation_from_runtime = Mock(return_value=True) + monkeypatch.setattr( + "alpasim_runtime.simulate.__main__.run_aggregation_from_runtime", + run_aggregation_from_runtime, + ) + + success = await run_simulation(_make_one_shot_args(log_dir=str(tmp_path))) + + assert success is True + generate_metrics_plot.assert_called_once() + run_aggregation_from_runtime.assert_called_once() + error_path = tmp_path / "prometheus" / "metrics_plot_error.txt" + assert error_path.read_text(encoding="utf-8") == ( + "RuntimeError: Prometheus query failed: bad result" + ) + + @pytest.mark.asyncio async def test_run_simulation_does_not_aggregate_failed_rollouts_by_default( monkeypatch: pytest.MonkeyPatch, ) -> None: fake_config = SimpleNamespace( - user=SimpleNamespace(nr_workers=1), + user=SimpleNamespace( + nr_workers=1, + prometheus=SimpleNamespace(url="http://prometheus-0:9090"), + ), ) fake_eval_config = SimpleNamespace(run_in_runtime=True, enabled=True) _patch_one_shot_inputs( @@ -567,10 +632,6 @@ async def test_run_simulation_does_not_aggregate_failed_rollouts_by_default( "alpasim_runtime.simulate.__main__.DaemonEngine", Mock(return_value=fake_engine), ) - monkeypatch.setattr( - "alpasim_runtime.simulate.__main__.merge_metrics_files", - Mock(), - ) monkeypatch.setattr( "alpasim_runtime.simulate.__main__.generate_metrics_plot", Mock(return_value="/tmp/log/metrics_plot.png"), @@ -597,7 +658,10 @@ async def test_run_simulation_aggregates_failed_rollouts_when_enabled( monkeypatch: pytest.MonkeyPatch, ) -> None: fake_config = SimpleNamespace( - user=SimpleNamespace(nr_workers=1), + user=SimpleNamespace( + nr_workers=1, + prometheus=SimpleNamespace(url="http://prometheus-0:9090"), + ), ) fake_eval_config = SimpleNamespace( run_in_runtime=True, @@ -631,10 +695,6 @@ async def test_run_simulation_aggregates_failed_rollouts_when_enabled( "alpasim_runtime.simulate.__main__.DaemonEngine", Mock(return_value=fake_engine), ) - monkeypatch.setattr( - "alpasim_runtime.simulate.__main__.merge_metrics_files", - Mock(), - ) monkeypatch.setattr( "alpasim_runtime.simulate.__main__.generate_metrics_plot", Mock(return_value="/tmp/log/metrics_plot.png"), @@ -670,7 +730,10 @@ async def test_run_simulation_one_shot_fails_when_result_count_mismatches_jobs( monkeypatch: pytest.MonkeyPatch, ) -> None: fake_config = SimpleNamespace( - user=SimpleNamespace(nr_workers=1), + user=SimpleNamespace( + nr_workers=1, + prometheus=SimpleNamespace(url="http://prometheus-0:9090"), + ), ) fake_eval_config = SimpleNamespace(run_in_runtime=False, enabled=False) _patch_one_shot_inputs( @@ -702,11 +765,6 @@ async def test_run_simulation_one_shot_fails_when_result_count_mismatches_jobs( Mock(return_value=fake_engine), ) - merge_metrics_files = Mock() - monkeypatch.setattr( - "alpasim_runtime.simulate.__main__.merge_metrics_files", - merge_metrics_files, - ) generate_metrics_plot = Mock() monkeypatch.setattr( "alpasim_runtime.simulate.__main__.generate_metrics_plot", @@ -723,7 +781,6 @@ async def test_run_simulation_one_shot_fails_when_result_count_mismatches_jobs( await run_simulation(args) fake_engine.shutdown.assert_awaited_once() - merge_metrics_files.assert_not_called() generate_metrics_plot.assert_not_called() @@ -732,7 +789,10 @@ async def test_run_simulation_one_shot_shutdowns_when_engine_simulate_raises( monkeypatch: pytest.MonkeyPatch, ) -> None: fake_config = SimpleNamespace( - user=SimpleNamespace(nr_workers=1), + user=SimpleNamespace( + nr_workers=1, + prometheus=SimpleNamespace(url="http://prometheus-0:9090"), + ), ) fake_eval_config = SimpleNamespace(run_in_runtime=False, enabled=False) _patch_one_shot_inputs( @@ -772,7 +832,10 @@ async def test_run_simulation_one_shot_shutdowns_when_engine_startup_raises( monkeypatch: pytest.MonkeyPatch, ) -> None: fake_config = SimpleNamespace( - user=SimpleNamespace(nr_workers=1), + user=SimpleNamespace( + nr_workers=1, + prometheus=SimpleNamespace(url="http://prometheus-0:9090"), + ), ) fake_eval_config = SimpleNamespace(run_in_runtime=False, enabled=False) _patch_one_shot_inputs( diff --git a/src/runtime/tests/test_daemon_scheduler.py b/src/runtime/tests/test_daemon_scheduler.py index d90eb02f..28d9b184 100644 --- a/src/runtime/tests/test_daemon_scheduler.py +++ b/src/runtime/tests/test_daemon_scheduler.py @@ -4,13 +4,20 @@ from __future__ import annotations import asyncio +from unittest.mock import AsyncMock, patch import pytest from alpasim_runtime.address_pool import AddressPool -from alpasim_runtime.daemon.scheduler import DaemonScheduler +from alpasim_runtime.daemon.scheduler import DaemonScheduler, SceneAffineDispatch from alpasim_runtime.worker.ipc import JobResult, PendingRolloutJob +def _affine_strategy(scheduler: DaemonScheduler) -> SceneAffineDispatch: + """Return the scheduler's strategy with proper typing for test assertions.""" + assert isinstance(scheduler._strategy, SceneAffineDispatch) + return scheduler._strategy + + def _make_pools(capacity_per_service: int) -> dict[str, AddressPool]: return { "driver": AddressPool(["driver:50051"], capacity_per_service, skip=False), @@ -25,6 +32,23 @@ def _make_pools(capacity_per_service: int) -> dict[str, AddressPool]: } +def _make_pools_multi_gpu( + n_concurrent: int = 1, +) -> dict[str, AddressPool]: + """Create pools with 2 renderer GPUs for affine tests.""" + return { + "driver": AddressPool(["driver:50051"], n_concurrent=2, skip=False), + "renderer": AddressPool( + ["gpu-0:50052", "gpu-1:50052"], + n_concurrent=n_concurrent, + skip=False, + ), + "physics": AddressPool(["physics:50053"], n_concurrent=2, skip=False), + "trafficsim": AddressPool(["trafficsim:50054"], n_concurrent=2, skip=False), + "controller": AddressPool(["controller:50055"], n_concurrent=2, skip=False), + } + + def _pending( job_id: str, scene_id: str = "scene-a", @@ -66,8 +90,13 @@ def check_for_crashes(self) -> None: return None +# --------------------------------------------------------------------------- +# Basic scheduling tests +# --------------------------------------------------------------------------- + + @pytest.mark.asyncio -async def test_scheduler_uses_global_fifo_queue() -> None: +async def test_scheduler_dispatches_jobs() -> None: runtime = _FakeRuntime() scheduler = DaemonScheduler( pools=_make_pools(capacity_per_service=1), @@ -192,3 +221,470 @@ async def test_scheduler_does_not_acquire_inactive_renderer_pool() -> None: assert runtime.submitted_jobs[0].endpoints.renderer.address == "sensorsim:50052" await scheduler.shutdown(reason="test cleanup") + + +# --------------------------------------------------------------------------- +# Scene-affine dispatch: tier 1/2/3 job selection +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_tier1_prefers_job_for_cached_scene() -> None: + """When a free GPU has scene-A cached, a pending scene-A job is preferred.""" + runtime = _FakeRuntime() + pools = _make_pools_multi_gpu() + scheduler = DaemonScheduler(pools=pools, runtime=runtime) + + # Simulate gRPC sync: gpu-0 has scene-A cached. + _affine_strategy(scheduler).sync_scene_cache("gpu-0:50052", ["scene-A"]) + + # Submit jobs for scene-B (new) and scene-A (cached on gpu-0). + await scheduler.submit_request( + "req-1", + [_pending("j2", scene_id="scene-B"), _pending("j3", scene_id="scene-A")], + ) + + # Both dispatched (2 GPUs available). j3 (scene-A) should go to gpu-0. + assert len(runtime.submitted_jobs) == 2 + j3_gpu = next( + j.endpoints.renderer.address for j in runtime.submitted_jobs if j.job_id == "j3" + ) + assert j3_gpu == "gpu-0:50052" + + await scheduler.shutdown(reason="test cleanup") + + +@pytest.mark.asyncio +async def test_tier2_prefers_new_scene_over_cached_elsewhere() -> None: + """When no free GPU has a matching cache, prefer a scene not cached anywhere. + + Setup: gpu-0 has scene-A cached, gpu-1 has scene-B cached (via sync). + Block gpu-1 with work, leaving gpu-0 (with A) free. + Submit jobs for scene-B (cached on busy gpu-1) and scene-C (new). + Tier 1 fails (gpu-0 has A, not B or C). + Tier 2 should prefer scene-C (not cached anywhere) over scene-B. + """ + runtime = _FakeRuntime() + pools = _make_pools_multi_gpu() + scheduler = DaemonScheduler(pools=pools, runtime=runtime) + + # Seed cache via sync (simulating gRPC introspection). + _affine_strategy(scheduler).sync_scene_cache("gpu-0:50052", ["scene-A"]) + _affine_strategy(scheduler).sync_scene_cache("gpu-1:50052", ["scene-B"]) + + # Block gpu-1 with work. + await scheduler.submit_request( + "req-block", [_pending("jblock", scene_id="scene-X")] + ) + # jblock acquires gpu-0 (FIFO front — tier 1 hit on scene-A? No, scene-X not cached). + # Actually tier 2: scene-X not cached → FIFO slot → gpu-0. + first_gpu = runtime.submitted_jobs[0].endpoints.renderer.address + + # Block the other GPU too if first went to gpu-0. + if first_gpu == "gpu-0:50052": + # gpu-1 is free with scene-B cached. Block it. + await scheduler.submit_request( + "req-block2", [_pending("jblock2", scene_id="scene-Y")] + ) + # Release gpu-0 so it's available. + scheduler.on_result(_result("req-block", "jblock")) + else: + # gpu-0 is free with scene-A cached. Good — gpu-1 is busy. + pass + + # Now we need gpu-0 free (with scene-A) and gpu-1 busy. + # Simpler approach: just release and re-block deterministically. + await scheduler.shutdown(reason="test cleanup") + + # Re-create with deterministic setup. + runtime = _FakeRuntime() + pools = _make_pools_multi_gpu() + scheduler = DaemonScheduler(pools=pools, runtime=runtime) + _affine_strategy(scheduler).sync_scene_cache("gpu-0:50052", ["scene-A"]) + _affine_strategy(scheduler).sync_scene_cache("gpu-1:50052", ["scene-B"]) + + # Acquire gpu-1 slot directly to block it, then submit. + slot = pools["renderer"].try_acquire_for_address("gpu-1:50052") + assert slot is not None # gpu-1 is now busy + + # Submit scene-B (cached on busy gpu-1) and scene-C (new). + await scheduler.submit_request( + "req-2", + [_pending("j3", scene_id="scene-B"), _pending("j4", scene_id="scene-C")], + ) + + # Only gpu-0 is free (has A cached, not B or C → tier 1 fails). + # Tier 2: scene-B is cached (gpu-1), scene-C is not cached → pick scene-C. + dispatched_job = runtime.submitted_jobs[0] + assert dispatched_job.job_id == "j4" + assert dispatched_job.scene_id == "scene-C" + + pools["renderer"].release(slot) + await scheduler.shutdown(reason="test cleanup") + + +@pytest.mark.asyncio +async def test_affine_same_scene_returns_to_same_gpu() -> None: + """A sync-seeded cache directs repeat jobs to the same GPU.""" + runtime = _FakeRuntime() + pools = _make_pools_multi_gpu() + scheduler = DaemonScheduler(pools=pools, runtime=runtime) + + # Simulate gRPC sync reporting scene-A on gpu-0. + _affine_strategy(scheduler).sync_scene_cache("gpu-0:50052", ["scene-A"]) + + # Submit scene-A job — should hit gpu-0 (tier-1). + await scheduler.submit_request("req-1", [_pending("j1", scene_id="scene-A")]) + first_gpu = runtime.submitted_jobs[0].endpoints.renderer.address + assert first_gpu == "gpu-0:50052" + + # Complete the job. + scheduler.on_result(_result("req-1", "j1")) + + # Submit another scene-A job — should still hit gpu-0 (cache still seeded). + await scheduler.submit_request("req-2", [_pending("j2", scene_id="scene-A")]) + second_gpu = runtime.submitted_jobs[1].endpoints.renderer.address + assert second_gpu == first_gpu + + await scheduler.shutdown(reason="test cleanup") + + +@pytest.mark.asyncio +async def test_different_scenes_get_different_gpus() -> None: + """Different scenes are routed to different GPUs when possible.""" + runtime = _FakeRuntime() + pools = _make_pools_multi_gpu() + scheduler = DaemonScheduler(pools=pools, runtime=runtime) + + # Simulate gRPC sync: gpu-0 has scene-A, gpu-1 has scene-B. + _affine_strategy(scheduler).sync_scene_cache("gpu-0:50052", ["scene-A"]) + _affine_strategy(scheduler).sync_scene_cache("gpu-1:50052", ["scene-B"]) + + # Dispatch scene-A — should hit gpu-0 (tier-1). + await scheduler.submit_request("req-1", [_pending("j1", scene_id="scene-A")]) + gpu_a = runtime.submitted_jobs[0].endpoints.renderer.address + assert gpu_a == "gpu-0:50052" + scheduler.on_result(_result("req-1", "j1")) + + # Dispatch scene-B — should hit gpu-1 (tier-1). + await scheduler.submit_request("req-2", [_pending("j2", scene_id="scene-B")]) + gpu_b = runtime.submitted_jobs[1].endpoints.renderer.address + assert gpu_b == "gpu-1:50052" + scheduler.on_result(_result("req-2", "j2")) + + # Dispatch scene-A again — should still get gpu-0. + await scheduler.submit_request("req-3", [_pending("j3", scene_id="scene-A")]) + gpu_a2 = runtime.submitted_jobs[2].endpoints.renderer.address + assert gpu_a2 == gpu_a + + # Dispatch scene-B again — should still get gpu-1. + scheduler.on_result(_result("req-3", "j3")) + await scheduler.submit_request("req-4", [_pending("j4", scene_id="scene-B")]) + gpu_b2 = runtime.submitted_jobs[3].endpoints.renderer.address + assert gpu_b2 == gpu_b + + await scheduler.shutdown(reason="test cleanup") + + +@pytest.mark.asyncio +async def test_sync_seeded_cache_preserves_diversity() -> None: + """Sync-seeded caches route each scene to its designated GPU. + + gpu-0 has scene-A, gpu-1 has scene-B. Sequential dispatches with + one GPU free at a time should always route to the correct GPU. + """ + runtime = _FakeRuntime() + pools = _make_pools_multi_gpu() + scheduler = DaemonScheduler(pools=pools, runtime=runtime) + + # Seed via sync. + _affine_strategy(scheduler).sync_scene_cache("gpu-0:50052", ["A"]) + _affine_strategy(scheduler).sync_scene_cache("gpu-1:50052", ["B"]) + + # Block gpu-1 so only gpu-0 is free. + gpu1_slot = pools["renderer"].try_acquire_for_address("gpu-1:50052") + assert gpu1_slot is not None + + # Submit scene-A — should route to gpu-0 (tier-1 hit). + await scheduler.submit_request("req-1", [_pending("a1", scene_id="A")]) + assert runtime.submitted_jobs[0].endpoints.renderer.address == "gpu-0:50052" + scheduler.on_result(_result("req-1", "a1")) + + # Free gpu-1, block gpu-0. + pools["renderer"].release(gpu1_slot) + gpu0_slot = pools["renderer"].try_acquire_for_address("gpu-0:50052") + assert gpu0_slot is not None + + # Submit scene-B — should route to gpu-1 (tier-1 hit). + await scheduler.submit_request("req-2", [_pending("b1", scene_id="B")]) + assert runtime.submitted_jobs[1].endpoints.renderer.address == "gpu-1:50052" + scheduler.on_result(_result("req-2", "b1")) + + # Free gpu-0, submit scene-A again — still hits gpu-0. + pools["renderer"].release(gpu0_slot) + gpu1_slot = pools["renderer"].try_acquire_for_address("gpu-1:50052") + assert gpu1_slot is not None + await scheduler.submit_request("req-3", [_pending("a2", scene_id="A")]) + assert runtime.submitted_jobs[2].endpoints.renderer.address == "gpu-0:50052" + + pools["renderer"].release(gpu1_slot) + await scheduler.shutdown(reason="test cleanup") + + +@pytest.mark.asyncio +async def test_affine_disabled_uses_plain_fifo() -> None: + """With scene_affine_dispatch=False, dispatch is pure FIFO.""" + runtime = _FakeRuntime() + pools = _make_pools_multi_gpu() + scheduler = DaemonScheduler( + pools=pools, runtime=runtime, scene_affine_dispatch=False + ) + + # Seed: dispatch scene-A on gpu-0, complete it. + await scheduler.submit_request("req-1", [_pending("j1", scene_id="scene-A")]) + scheduler.on_result(_result("req-1", "j1")) + + # Submit scene-B and scene-A. With affine off, the scheduler just picks + # any job — no preference for scene-A on the cached GPU. + await scheduler.submit_request( + "req-2", + [_pending("j2", scene_id="scene-B"), _pending("j3", scene_id="scene-A")], + ) + + # Both should dispatch (2 GPUs), but order is not scene-aware. + assert len(runtime.submitted_jobs) == 3 + + # FifoDispatch has no affine tracking at all. + assert not hasattr(scheduler._strategy, "_affine_hits") + + await scheduler.shutdown(reason="test cleanup") + + +@pytest.mark.asyncio +async def test_warm_started_pool_produces_tier1_hit() -> None: + """A warm-started pool should produce tier-1 hits on the very first dispatch.""" + runtime = _FakeRuntime() + pools = _make_pools_multi_gpu() + + scheduler = DaemonScheduler(pools=pools, runtime=runtime) + + # Pre-seed the strategy's cache as SceneAffineDispatch.warm_start() would. + _affine_strategy(scheduler).sync_scene_cache("gpu-0:50052", ["scene-X"]) + _affine_strategy(scheduler).sync_scene_cache("gpu-1:50052", ["scene-Y"]) + + # Submit scene-X — should hit gpu-0 (tier-1: cached + free). + await scheduler.submit_request("req-1", [_pending("j1", scene_id="scene-X")]) + assert runtime.submitted_jobs[0].endpoints.renderer.address == "gpu-0:50052" + assert _affine_strategy(scheduler)._affine_hits == 1 + + # Complete and submit scene-Y — should hit gpu-1 (tier-1). + scheduler.on_result(_result("req-1", "j1")) + await scheduler.submit_request("req-2", [_pending("j2", scene_id="scene-Y")]) + assert runtime.submitted_jobs[1].endpoints.renderer.address == "gpu-1:50052" + assert _affine_strategy(scheduler)._affine_hits == 2 + + await scheduler.shutdown(reason="test cleanup") + + +# --------------------------------------------------------------------------- +# warm_start integration tests +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_warm_start_seeds_cache_and_starts_refresh() -> None: + """warm_start() should query NRE, seed the cache, and start the refresh loop.""" + runtime = _FakeRuntime() + pools = _make_pools_multi_gpu() + + responses = { + "gpu-0:50052": {"scene-A": 2}, + "gpu-1:50052": {"scene-B": 1}, + } + + async def _fake_get(address: str, **kwargs): + return responses.get(address, {}) + + with patch("alpasim_runtime.daemon.scheduler.get_loaded_scenes", _fake_get): + scheduler = DaemonScheduler( + pools=pools, + runtime=runtime, + cache_refresh_interval_s=60.0, + ) + # Before warm_start, no cache and no refresh task. + assert not _affine_strategy(scheduler).is_scene_cached("scene-A") + assert _affine_strategy(scheduler)._cache_refresh_task is None + + await scheduler.warm_start() + + # After warm_start, cache is seeded and refresh task is running. + strategy = _affine_strategy(scheduler) + assert strategy.is_scene_cached("scene-A") + assert strategy.is_scene_cached("scene-B") + assert strategy._cache_refresh_task is not None + assert not strategy._cache_refresh_task.done() + + await scheduler.shutdown(reason="test cleanup") + + +@pytest.mark.asyncio +async def test_warm_start_raises_on_unimplemented() -> None: + """warm_start() should raise IntrospectionNotSupportedError on UNIMPLEMENTED.""" + from alpasim_runtime.nre_introspection import IntrospectionNotSupportedError + + runtime = _FakeRuntime() + pools = _make_pools_multi_gpu() + + async def _fake_get(address: str, **kwargs): + if kwargs.get("raise_on_unimplemented"): + raise IntrospectionNotSupportedError( + f"NRE at {address} does not support GetLoadedScenes" + ) + return None + + with patch("alpasim_runtime.daemon.scheduler.get_loaded_scenes", _fake_get): + scheduler = DaemonScheduler( + pools=pools, + runtime=runtime, + cache_refresh_interval_s=5.0, + ) + with pytest.raises(IntrospectionNotSupportedError): + await scheduler.warm_start() + + # Refresh task should NOT have been started on failure. + assert _affine_strategy(scheduler)._cache_refresh_task is None + + await scheduler.shutdown(reason="test cleanup") + + +@pytest.mark.asyncio +async def test_warm_start_partial_failure_continues() -> None: + """warm_start() should continue if some addresses return None (transient failure).""" + runtime = _FakeRuntime() + pools = _make_pools_multi_gpu() + + async def _fake_get(address: str, **kwargs): + if address == "gpu-0:50052": + return {"scene-A": 1} + return None # gpu-1 is unreachable + + with patch("alpasim_runtime.daemon.scheduler.get_loaded_scenes", _fake_get): + scheduler = DaemonScheduler( + pools=pools, + runtime=runtime, + cache_refresh_interval_s=60.0, + ) + await scheduler.warm_start() + + # Only gpu-0's scenes should be cached. + assert _affine_strategy(scheduler).is_scene_cached("scene-A") + assert not _affine_strategy(scheduler).is_scene_cached("scene-B") + # Refresh task should still start. + assert _affine_strategy(scheduler)._cache_refresh_task is not None + + await scheduler.shutdown(reason="test cleanup") + + +# --------------------------------------------------------------------------- +# Periodic cache refresh tests +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_cache_refresh_updates_routing() -> None: + """The periodic refresh should update the cache so the next dispatch benefits.""" + runtime = _FakeRuntime() + pools = _make_pools_multi_gpu() + + call_count = 0 + responses = { + "gpu-0:50052": {"scene-Z": 2}, + "gpu-1:50052": {"scene-W": 1}, + } + + async def _fake_get(address: str, **kwargs): + nonlocal call_count + call_count += 1 + return responses.get(address, {}) + + with patch("alpasim_runtime.daemon.scheduler.get_loaded_scenes", _fake_get): + scheduler = DaemonScheduler( + pools=pools, + runtime=runtime, + cache_refresh_interval_s=0.05, + ) + # warm_start seeds the cache and starts the refresh loop. + await scheduler.warm_start() + # Let the refresh loop fire at least once after warm_start. + await asyncio.sleep(0.15) + + # The strategy should now know about scene-Z on gpu-0 and scene-W on gpu-1. + assert _affine_strategy(scheduler).is_scene_cached("scene-Z") + assert _affine_strategy(scheduler).is_scene_cached("scene-W") + assert call_count >= 2 # at least one full cycle (2 addresses) + + # Dispatch scene-Z — should hit gpu-0 (tier-1). + await scheduler.submit_request("req-1", [_pending("j1", scene_id="scene-Z")]) + assert runtime.submitted_jobs[0].endpoints.renderer.address == "gpu-0:50052" + assert _affine_strategy(scheduler)._affine_hits == 1 + + await scheduler.shutdown(reason="test cleanup") + + +@pytest.mark.asyncio +async def test_cache_refresh_disabled_when_none() -> None: + """cache_refresh_interval_s=None should not create a refresh task.""" + runtime = _FakeRuntime() + pools = _make_pools_multi_gpu() + + scheduler = DaemonScheduler( + pools=pools, + runtime=runtime, + cache_refresh_interval_s=None, + ) + assert _affine_strategy(scheduler)._cache_refresh_task is None + await scheduler.shutdown(reason="test cleanup") + + +@pytest.mark.asyncio +async def test_cache_refresh_disabled_when_affine_off() -> None: + """With scene_affine_dispatch=False, no refresh task should be created.""" + runtime = _FakeRuntime() + pools = _make_pools_multi_gpu() + + scheduler = DaemonScheduler( + pools=pools, + runtime=runtime, + scene_affine_dispatch=False, + cache_refresh_interval_s=30.0, + ) + # FifoDispatch has no cache refresh mechanism. + assert not hasattr(scheduler._strategy, "_cache_refresh_task") + await scheduler.shutdown(reason="test cleanup") + + +@pytest.mark.asyncio +async def test_shutdown_cancels_refresh_task() -> None: + """shutdown() should cleanly cancel the refresh task.""" + runtime = _FakeRuntime() + pools = _make_pools_multi_gpu() + + mock_get = AsyncMock(return_value={"scene-A": 1}) + + with patch("alpasim_runtime.daemon.scheduler.get_loaded_scenes", mock_get): + scheduler = DaemonScheduler( + pools=pools, + runtime=runtime, + cache_refresh_interval_s=60.0, + ) + strategy = _affine_strategy(scheduler) + # Refresh task is not started until warm_start. + assert strategy._cache_refresh_task is None + + await scheduler.warm_start() + assert strategy._cache_refresh_task is not None + assert not strategy._cache_refresh_task.done() + + await scheduler.shutdown(reason="test cleanup") + + assert strategy._cache_refresh_task.done() diff --git a/src/runtime/tests/test_delay_buffer.py b/src/runtime/tests/test_delay_buffer.py index 08448c66..ab71f85d 100644 --- a/src/runtime/tests/test_delay_buffer.py +++ b/src/runtime/tests/test_delay_buffer.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# Copyright (c) 2025 NVIDIA Corporation +# Copyright (c) 2025-2026 NVIDIA Corporation import pytest from alpasim_runtime.delay_buffer import DelayBuffer diff --git a/src/runtime/tests/test_nre_introspection.py b/src/runtime/tests/test_nre_introspection.py new file mode 100644 index 00000000..f6750fea --- /dev/null +++ b/src/runtime/tests/test_nre_introspection.py @@ -0,0 +1,158 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation + +"""Tests for NRE introspection helpers.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from alpasim_runtime.nre_introspection import ( + IntrospectionNotSupportedError, + get_loaded_scenes, +) + +import grpc +import grpc.aio + + +def _make_aio_rpc_error( + code: grpc.StatusCode, details: str = "" +) -> grpc.aio.AioRpcError: + """Construct an AioRpcError for testing.""" + return grpc.aio.AioRpcError( + code=code, + initial_metadata=grpc.aio.Metadata(), + trailing_metadata=grpc.aio.Metadata(), + details=details, + ) + + +class TestGetLoadedScenes: + """Tests for get_loaded_scenes.""" + + @pytest.mark.asyncio + async def test_parses_response_correctly(self): + """Successful response should be parsed into {scene_id: count} dict.""" + mock_channel = AsyncMock() + + entry_a = MagicMock() + entry_a.scene_id = "scene-A" + entry_a.loaded_instance_count = 3 + entry_b = MagicMock() + entry_b.scene_id = "scene-B" + entry_b.loaded_instance_count = 1 + + mock_response = MagicMock() + mock_response.scenes = [entry_a, entry_b] + + mock_stub = MagicMock() + mock_stub.get_loaded_scenes = AsyncMock(return_value=mock_response) + + with ( + patch( + "alpasim_runtime.nre_introspection.grpc.aio.insecure_channel", + return_value=mock_channel, + ), + patch( + "alpasim_runtime.nre_introspection.SensorsimServiceStub", + return_value=mock_stub, + ), + ): + result = await get_loaded_scenes("gpu-0:50052") + + assert result == {"scene-A": 3, "scene-B": 1} + mock_channel.close.assert_awaited_once() + + @pytest.mark.asyncio + async def test_returns_none_on_grpc_error(self): + """Any gRPC error should return None (logged as warning).""" + mock_channel = AsyncMock() + mock_stub = MagicMock() + mock_stub.get_loaded_scenes = AsyncMock( + side_effect=_make_aio_rpc_error( + grpc.StatusCode.UNAVAILABLE, "connection refused" + ) + ) + + with ( + patch( + "alpasim_runtime.nre_introspection.grpc.aio.insecure_channel", + return_value=mock_channel, + ), + patch( + "alpasim_runtime.nre_introspection.SensorsimServiceStub", + return_value=mock_stub, + ), + ): + result = await get_loaded_scenes("gpu-0:50052") + assert result is None + + @pytest.mark.asyncio + async def test_returns_none_on_unimplemented_by_default(self): + """UNIMPLEMENTED returns None when raise_on_unimplemented is False.""" + mock_channel = AsyncMock() + mock_stub = MagicMock() + mock_stub.get_loaded_scenes = AsyncMock( + side_effect=_make_aio_rpc_error(grpc.StatusCode.UNIMPLEMENTED) + ) + + with ( + patch( + "alpasim_runtime.nre_introspection.grpc.aio.insecure_channel", + return_value=mock_channel, + ), + patch( + "alpasim_runtime.nre_introspection.SensorsimServiceStub", + return_value=mock_stub, + ), + ): + result = await get_loaded_scenes("gpu-0:50052") + assert result is None + + @pytest.mark.asyncio + async def test_raises_on_unimplemented_when_requested(self): + """UNIMPLEMENTED raises IntrospectionNotSupportedError at startup.""" + mock_channel = AsyncMock() + mock_stub = MagicMock() + mock_stub.get_loaded_scenes = AsyncMock( + side_effect=_make_aio_rpc_error(grpc.StatusCode.UNIMPLEMENTED) + ) + + with ( + patch( + "alpasim_runtime.nre_introspection.grpc.aio.insecure_channel", + return_value=mock_channel, + ), + patch( + "alpasim_runtime.nre_introspection.SensorsimServiceStub", + return_value=mock_stub, + ), + ): + with pytest.raises(IntrospectionNotSupportedError): + await get_loaded_scenes("gpu-0:50052", raise_on_unimplemented=True) + + @pytest.mark.asyncio + async def test_transient_error_returns_none_even_with_raise_flag(self): + """Transient errors (UNAVAILABLE) still return None even with the flag.""" + mock_channel = AsyncMock() + mock_stub = MagicMock() + mock_stub.get_loaded_scenes = AsyncMock( + side_effect=_make_aio_rpc_error( + grpc.StatusCode.UNAVAILABLE, "connection refused" + ) + ) + + with ( + patch( + "alpasim_runtime.nre_introspection.grpc.aio.insecure_channel", + return_value=mock_channel, + ), + patch( + "alpasim_runtime.nre_introspection.SensorsimServiceStub", + return_value=mock_stub, + ), + ): + result = await get_loaded_scenes("gpu-0:50052", raise_on_unimplemented=True) + assert result is None diff --git a/src/runtime/tests/test_runtime_integration_replay.py b/src/runtime/tests/test_runtime_integration_replay.py index 7713d289..ded2edc0 100644 --- a/src/runtime/tests/test_runtime_integration_replay.py +++ b/src/runtime/tests/test_runtime_integration_replay.py @@ -227,6 +227,10 @@ def runtime_configs(test_data_dir: Path, tmp_path: Path) -> Dict[str, str]: """ user_config_path = test_data_dir / REQUIRED_TEST_FILES["user_config"] user_config = yaml.safe_load(user_config_path.read_text(encoding="utf-8")) + user_config["prometheus"] = { + "worker_ports": [0], + "url": "http://127.0.0.1:9090", + } test_user_config = tmp_path / "test-user-config.yaml" test_user_config.write_text(yaml.dump(user_config), encoding="utf-8") @@ -250,7 +254,14 @@ def runtime_configs(test_data_dir: Path, tmp_path: Path) -> Dict[str, str]: # Create run_metadata.yaml required by get_run_name() run_metadata = log_dir / "run_metadata.yaml" - run_metadata.write_text("run_name: integration_replay_test\n") + run_metadata.write_text( + yaml.safe_dump( + { + "run_uuid": "integration-replay-run", + "run_name": "integration_replay_test", + } + ) + ) eval_config_path = test_data_dir / REQUIRED_TEST_FILES["eval_config"] diff --git a/src/runtime/tests/test_telemetry_context.py b/src/runtime/tests/test_telemetry_context.py new file mode 100644 index 00000000..9ef49d91 --- /dev/null +++ b/src/runtime/tests/test_telemetry_context.py @@ -0,0 +1,104 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation + +import asyncio + +import alpasim_runtime.telemetry.rpc_wrapper as rpc_wrapper +import alpasim_runtime.telemetry.telemetry_context as telemetry_context +import pytest +from alpasim_runtime.telemetry.telemetry_context import TelemetryContext +from prometheus_client import generate_latest + + +def test_record_rollout_complete_updates_simulation_summary_metrics() -> None: + ctx = TelemetryContext(worker_id=0) + ctx._simulation_started_at = 10.0 + now = 25.0 + + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr(telemetry_context, "perf_counter", lambda: now) + ctx.record_rollout_complete() + + metrics = generate_latest(ctx.registry).decode("utf-8") + assert ('alpasim_simulation_rollouts_completed_total{worker_id="0"} 1.0') in metrics + assert ('alpasim_simulation_elapsed_seconds{worker_id="0"} 15.0') in metrics + + +def test_refresh_gauges_snapshots_event_loop_and_gc_stats(monkeypatch) -> None: + ctx = TelemetryContext(worker_id=0) + monkeypatch.setattr( + telemetry_context, + "get_event_loop_idle_stats", + lambda: { + "idle_seconds": 1.0, + "poll_seconds": 2.0, + "work_seconds": 3.0, + "select_calls": 4, + }, + ) + monkeypatch.setattr( + telemetry_context, + "get_gc_pressure_stats", + lambda: { + "total_duration_s": 4.0, + "max_duration_s": 5.0, + "collection_count": 6, + "collected_total": 7, + "gen0_count": 8, + "gen1_count": 9, + "gen2_count": 10, + }, + ) + + ctx.refresh_gauges() + + metrics = generate_latest(ctx.registry).decode("utf-8") + assert ('alpasim_event_loop_idle_seconds_total{worker_id="0"} 1.0') in metrics + assert ('alpasim_event_loop_poll_seconds_total{worker_id="0"} 2.0') in metrics + assert ('alpasim_event_loop_work_seconds_total{worker_id="0"} 3.0') in metrics + assert ('alpasim_gc_total_duration_seconds{worker_id="0"} 4.0') in metrics + assert ('alpasim_gc_max_duration_seconds{worker_id="0"} 5.0') in metrics + assert ('alpasim_gc_collection_count_total{worker_id="0"} 6.0') in metrics + + +@pytest.mark.asyncio +async def test_profiled_rpc_call_records_latest_queue_depth_gauge( + monkeypatch, +) -> None: + ctx = TelemetryContext(worker_id=3) + monkeypatch.setattr(rpc_wrapper, "try_get_context", lambda: ctx) + + first_done = asyncio.Event() + second_done = asyncio.Event() + first_task = asyncio.create_task( + rpc_wrapper.profiled_rpc_call( + "render_rgb", + "sensorsim", + lambda: asyncio.create_task(first_done.wait()), + ) + ) + await asyncio.sleep(0) + + second_task = asyncio.create_task( + rpc_wrapper.profiled_rpc_call( + "render_rgb", + "sensorsim", + lambda: asyncio.create_task(second_done.wait()), + ) + ) + await asyncio.sleep(0) + second_done.set() + await second_task + + metrics = generate_latest(ctx.registry).decode("utf-8") + latest_metric = ( + 'alpasim_rpc_queue_depth_at_start_latest{service="sensorsim",' + 'tag="default",worker_id="3"}' + ) + assert f"{latest_metric} 1.0" in metrics + + first_done.set() + await first_task + + metrics = generate_latest(ctx.registry).decode("utf-8") + assert f"{latest_metric} 0.0" in metrics diff --git a/src/runtime/tests/test_with_mocks.py b/src/runtime/tests/test_with_mocks.py index 3e0c6f65..5039d4c6 100644 --- a/src/runtime/tests/test_with_mocks.py +++ b/src/runtime/tests/test_with_mocks.py @@ -37,6 +37,18 @@ ) +def _write_run_metadata(log_dir: Path, run_name: str) -> None: + (log_dir / "run_metadata.yaml").write_text( + yaml.safe_dump( + { + "run_uuid": f"{run_name}-uuid", + "run_name": run_name, + } + ), + encoding="utf-8", + ) + + def _make_available_cameras() -> sensorsim_pb2.AvailableCamerasReturn: response = sensorsim_pb2.AvailableCamerasReturn() for logical_id in _MOCK_CAMERA_IDS: @@ -129,7 +141,11 @@ async def _start_fake_sensorsim_server( def _write_sensorsim_mock_configs( - tmp_path: Path, sensorsim_address: str, *, batch_render: bool = False + tmp_path: Path, + sensorsim_address: str, + *, + telemetry_worker_port: int, + batch_render: bool = False, ) -> dict: base_user_config = yaml.safe_load( (_MOCK_DATA_DIR / "user-config.yaml").read_text(encoding="utf-8") @@ -137,6 +153,10 @@ def _write_sensorsim_mock_configs( base_user_config["scene_provider"]["usdz"]["data_dir"] = str(_MOCK_DATA_DIR) base_user_config["endpoints"]["renderer"]["skip"] = False base_user_config["endpoints"]["renderer"]["n_concurrent_rollouts"] = 1 + base_user_config["prometheus"] = { + "worker_ports": [telemetry_worker_port], + "url": "http://127.0.0.1:9090", + } base_user_config["simulation_config"]["n_sim_steps"] = 1 if batch_render: base_user_config["simulation_config"]["render_bundling"] = "BATCH_RENDER_RGB" @@ -176,10 +196,13 @@ async def test_sensorsim_mocks_with_fake_server(tmp_path: Path): servicer = _FakeSensorsimServicer() server, address = await _start_fake_sensorsim_server(servicer) try: - configs = _write_sensorsim_mock_configs(tmp_path, address) + configs = _write_sensorsim_mock_configs( + tmp_path, + address, + telemetry_worker_port=0, + ) - run_metadata = tmp_path / "run_metadata.yaml" - run_metadata.write_text("run_name: test_sensorsim_mocks\n") + _write_run_metadata(tmp_path, "test_sensorsim_mocks") parser = create_arg_parser() parsed_args = parser.parse_args( @@ -210,10 +233,14 @@ async def test_sensorsim_mocks_batch_render(tmp_path: Path): servicer = _FakeSensorsimServicer() server, address = await _start_fake_sensorsim_server(servicer) try: - configs = _write_sensorsim_mock_configs(tmp_path, address, batch_render=True) + configs = _write_sensorsim_mock_configs( + tmp_path, + address, + telemetry_worker_port=0, + batch_render=True, + ) - run_metadata = tmp_path / "run_metadata.yaml" - run_metadata.write_text("run_name: test_sensorsim_batch\n") + _write_run_metadata(tmp_path, "test_sensorsim_batch") parser = create_arg_parser() parsed_args = parser.parse_args( @@ -297,7 +324,12 @@ async def _start_fake_video_model_server( return server, f"127.0.0.1:{port}" -def _write_video_model_mock_configs(tmp_path: Path, video_model_address: str) -> dict: +def _write_video_model_mock_configs( + tmp_path: Path, + video_model_address: str, + *, + telemetry_worker_port: int, +) -> dict: base_user_config = yaml.safe_load( (_MOCK_DATA_DIR / "user-config.yaml").read_text(encoding="utf-8") ) @@ -306,6 +338,10 @@ def _write_video_model_mock_configs(tmp_path: Path, video_model_address: str) -> base_user_config.pop("extra_cameras", None) base_user_config["endpoints"]["renderer"]["skip"] = False base_user_config["endpoints"]["renderer"]["n_concurrent_rollouts"] = 1 + base_user_config["prometheus"] = { + "worker_ports": [telemetry_worker_port], + "url": "http://127.0.0.1:9090", + } base_user_config["renderer"] = { "kind": "video_model", "video_model_config": { @@ -355,10 +391,13 @@ async def test_video_model_mocks(tmp_path: Path): servicer = _FakeWorldModelServicer() server, address = await _start_fake_video_model_server(servicer) try: - configs = _write_video_model_mock_configs(tmp_path, address) + configs = _write_video_model_mock_configs( + tmp_path, + address, + telemetry_worker_port=0, + ) - run_metadata = tmp_path / "run_metadata.yaml" - run_metadata.write_text("run_name: test_video_model_mocks\n") + _write_run_metadata(tmp_path, "test_video_model_mocks") parser = create_arg_parser() parsed_args = parser.parse_args( diff --git a/src/tools/scripts/start-prometheus-grafana.sh b/src/tools/scripts/start-prometheus-grafana.sh new file mode 100755 index 00000000..32669f00 --- /dev/null +++ b/src/tools/scripts/start-prometheus-grafana.sh @@ -0,0 +1,320 @@ +#!/usr/bin/env bash +set -euo pipefail + +readonly PROMETHEUS_IMAGE="prom/prometheus:v2.55.1" +readonly GRAFANA_IMAGE="grafana/grafana:11.5.2" + +usage() { + cat <<'USAGE' +Usage: + start-prometheus-grafana.sh [start] [options] + start-prometheus-grafana.sh stop + +Arguments: + file-sd-dir Local path or SSH path. + Examples: + /tmp/alpasim-prometheus/file-sd + iad:/lustre/.../prometheus/file-sd + user@iad-login:/lustre/.../prometheus/file-sd + +Options: + --prometheus-port PORT Host port for Prometheus. Default: first open >= 9090 + --grafana-port PORT Host port for Grafana. Default: first open >= 3000 + -h, --help Show this help. +USAGE +} + +find_open_port() { + python3 - "$1" <<'PY' +import socket +import sys + +port = int(sys.argv[1]) +while True: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + try: + sock.bind(("127.0.0.1", port)) + except OSError: + port += 1 + continue + print(port) + break +PY +} + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "Missing required command: $1" >&2 + exit 1 + fi +} + +is_remote_path() { + local path="$1" + [[ "$path" == *:* && "$path" != /* ]] +} + +shell_quote() { + printf "%q" "$1" +} + +ensure_remote_dir() { + local remote="$1" + local host="${remote%%:*}" + local remote_path="${remote#*:}" + ssh "$host" "mkdir -p -- $(shell_quote "$remote_path")" +} + +unmount_sshfs() { + local mount_dir="$1" + if [[ ! -d "$mount_dir" ]] || ! mountpoint -q "$mount_dir"; then + return + fi + + if command -v fusermount3 >/dev/null 2>&1; then + fusermount3 -u "$mount_dir" + elif command -v fusermount >/dev/null 2>&1; then + fusermount -u "$mount_dir" + else + umount "$mount_dir" + fi +} + +mount_remote_file_sd() { + local remote="$1" + local mount_dir="$2" + + require_command ssh + require_command sshfs + require_command mountpoint + + mkdir -p "$mount_dir" + ensure_remote_dir "$remote" + unmount_sshfs "$mount_dir" + + if ! sshfs "$remote" "$mount_dir" \ + -o ro,reconnect,ServerAliveInterval=15,ServerAliveCountMax=3,allow_other; then + echo "Failed to mount remote file-SD directory with sshfs." >&2 + echo "Docker needs the SSHFS mount to use allow_other so Prometheus can read it." >&2 + echo "Enable it by setting 'user_allow_other' in /etc/fuse.conf, then rerun this script." >&2 + exit 1 + fi +} + +make_world_readable() { + local path + for path in "$@"; do + find "$path" -type d -exec chmod 755 {} + + find "$path" -type f -exec chmod 644 {} + + done +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" + +ACTION="start" +if [[ $# -gt 0 ]]; then + case "$1" in + -h|--help) + usage + exit 0 + ;; + start|up) + ACTION="start" + shift + ;; + stop) + ACTION="stop" + shift + ;; + esac +fi + +FILE_SD_SOURCE="" +PROMETHEUS_PORT="" +GRAFANA_PORT="" +USER_NAME="${USER:-user}" +WORK_DIR="${TMPDIR:-/tmp}/alpasim-local-telemetry-${USER_NAME}" + +while [[ $# -gt 0 ]]; do + case "$1" in + --prometheus-port) + PROMETHEUS_PORT="${2:?Missing value for --prometheus-port}" + shift 2 + ;; + --grafana-port) + GRAFANA_PORT="${2:?Missing value for --grafana-port}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + -*) + echo "Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + *) + if [[ -n "$FILE_SD_SOURCE" ]]; then + echo "Unexpected extra argument: $1" >&2 + usage >&2 + exit 1 + fi + FILE_SD_SOURCE="$1" + shift + ;; + esac +done + +COMPOSE_FILE="$WORK_DIR/docker-compose.yaml" +SSHFS_FILE_SD_DIR="$WORK_DIR/file-sd-sshfs" + +if [[ "$ACTION" == "stop" ]]; then + if [[ -f "$COMPOSE_FILE" ]]; then + require_command docker + docker compose -f "$COMPOSE_FILE" down + else + echo "No local telemetry compose file found at $COMPOSE_FILE" >&2 + fi + if command -v mountpoint >/dev/null 2>&1; then + unmount_sshfs "$SSHFS_FILE_SD_DIR" + fi + exit 0 +fi + +if [[ -z "$FILE_SD_SOURCE" ]]; then + echo "Missing required file-SD directory argument." >&2 + usage >&2 + exit 1 +fi + +require_command docker +require_command python3 +docker compose version >/dev/null + +if [[ -z "$PROMETHEUS_PORT" ]]; then + PROMETHEUS_PORT="$(find_open_port 9090)" +fi +if [[ -z "$GRAFANA_PORT" ]]; then + GRAFANA_PORT="$(find_open_port 3000)" +fi + +PROMETHEUS_DIR="$WORK_DIR/prometheus" +GRAFANA_PROVISIONING_DIR="$WORK_DIR/grafana/provisioning" +GRAFANA_DASHBOARDS_DIR="$WORK_DIR/grafana/dashboards" +GRAFANA_PLUGIN_PROVISIONING_DIR="$GRAFANA_PROVISIONING_DIR/plugins" +GRAFANA_ALERTING_PROVISIONING_DIR="$GRAFANA_PROVISIONING_DIR/alerting" + +mkdir -p \ + "$PROMETHEUS_DIR/rules" \ + "$GRAFANA_PROVISIONING_DIR/datasources" \ + "$GRAFANA_PROVISIONING_DIR/dashboards" \ + "$GRAFANA_PLUGIN_PROVISIONING_DIR" \ + "$GRAFANA_ALERTING_PROVISIONING_DIR" \ + "$GRAFANA_DASHBOARDS_DIR" + +if is_remote_path "$FILE_SD_SOURCE"; then + FILE_SD_DIR="$SSHFS_FILE_SD_DIR" + mount_remote_file_sd "$FILE_SD_SOURCE" "$FILE_SD_DIR" +else + FILE_SD_DIR="$FILE_SD_SOURCE" + mkdir -p "$FILE_SD_DIR" + if command -v mountpoint >/dev/null 2>&1; then + unmount_sshfs "$SSHFS_FILE_SD_DIR" + fi +fi + +cp "$REPO_ROOT/src/utils/alpasim_utils/telemetry/metrics_plot_recording_rules.yml" \ + "$PROMETHEUS_DIR/rules/alpasim-recording-rules.yml" +cp "$REPO_ROOT/src/utils/alpasim_utils/telemetry/alpasim-runtime-dashboard.json" \ + "$GRAFANA_DASHBOARDS_DIR/alpasim-runtime.json" + +cat >"$PROMETHEUS_DIR/prometheus.yml" <<'EOF' +global: + scrape_interval: 5s + evaluation_interval: 5s + +rule_files: + - /etc/prometheus/rules/*.yml + +scrape_configs: + - job_name: alpasim + file_sd_configs: + - files: + - file-sd/*.json + refresh_interval: 5s +EOF + +cat >"$GRAFANA_PROVISIONING_DIR/datasources/prometheus.yaml" <<'EOF' +apiVersion: 1 +datasources: + - name: Prometheus + uid: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true +EOF + +cat >"$GRAFANA_PROVISIONING_DIR/dashboards/alpasim.yaml" <<'EOF' +apiVersion: 1 +providers: + - name: AlpaSim + type: file + options: + path: /var/lib/grafana/dashboards +EOF + +make_world_readable "$PROMETHEUS_DIR" "$GRAFANA_PROVISIONING_DIR" "$GRAFANA_DASHBOARDS_DIR" + +cat >"$COMPOSE_FILE" < torch.Tensor: + obstacle_class_name_2_id = env_data["metadata"]["obstacle_class_name_2_id"] + agent_type = torch.full( + (class_ids.shape[0],), + AGENT_TYPE_OTHER, + dtype=torch.long, + device=device, + ) + for class_name in ("car", "truck"): + if class_name in obstacle_class_name_2_id: + agent_type[class_ids == obstacle_class_name_2_id[class_name]] = ( + AGENT_TYPE_VEHICLE + ) + if "pedestrian" in obstacle_class_name_2_id: + agent_type[class_ids == obstacle_class_name_2_id["pedestrian"]] = ( + AGENT_TYPE_PEDESTRIAN + ) + if "cyclist" in obstacle_class_name_2_id: + agent_type[class_ids == obstacle_class_name_2_id["cyclist"]] = ( + AGENT_TYPE_CYCLIST + ) + if "others" in obstacle_class_name_2_id: + agent_type[class_ids == obstacle_class_name_2_id["others"]] = AGENT_TYPE_OTHER + return agent_type + + +def _polyline_inside_mask( + polylines: torch.Tensor, center_xyz: torch.Tensor, dist_th_sq: float +) -> torch.Tensor: + coord_dim = min(polylines.shape[-1], center_xyz.shape[-1]) + center = center_xyz.reshape(1, 1, -1)[..., :coord_dim] + dist_sq = (polylines[..., :coord_dim] - center).pow(2).sum(dim=-1) + return dist_sq.lt(dist_th_sq).sum(dim=1) > 0 + + +def _filter_polylines_and_labels(map_element: dict, is_inside: torch.Tensor) -> bool: + if is_inside.sum() == 0: + return False + + map_element["polylines"] = map_element["polylines"][is_inside] + + for optional_key in ("label", "polylines_styles", "polylines_colors"): + value = map_element.get(optional_key) + if torch.is_tensor(value) and value.shape[:1] == is_inside.shape[:1]: + map_element[optional_key] = value[is_inside.to(value.device)] + + # another optional key + polylines_attrs = map_element.get("polylines_attrs") + if isinstance(polylines_attrs, dict): + keep = is_inside.detach().cpu().tolist() + filtered_attrs = {} + for attr, attr_val in polylines_attrs.items(): + if isinstance(attr_val, list) and len(attr_val) == len(keep): + filtered_attrs[attr] = [ + item for item, keep_item in zip(attr_val, keep) if keep_item + ] + elif ( + torch.is_tensor(attr_val) and attr_val.shape[:1] == is_inside.shape[:1] + ): + filtered_attrs[attr] = attr_val[is_inside.to(attr_val.device)] + else: + filtered_attrs[attr] = attr_val + map_element["polylines_attrs"] = filtered_attrs + + return True + + +def load_model_config(yaml_path): + assert os.path.exists(yaml_path), f"Config file not found: {yaml_path}" + with open(yaml_path, "r") as f: + content = f.read() + cfg = OmegaConf.create(yaml.safe_load(content)) + OmegaConf.resolve(cfg) + return cfg + + +def filter_map(env_data, center_xyz: torch.Tensor, distance_th: float): + """ + Filter new EnvData map polylines by ego position. + + The artifact-backed map path uses per-polyline ``label`` values as the + source of map type information, so this path only requires ``polylines`` + and ``label`` on each map element. + """ + assert distance_th > 0 + + map_data = env_data["map"] + element_names = list(map_data.keys()) + center_xyz = center_xyz.reshape(1, 3) + dist_th_sq = distance_th**2 + + for e in element_names: + map_element = map_data[e] + + if not isinstance(map_element, dict): + continue + + if "polylines" not in map_element: + continue + + polylines = map_element["polylines"] + if polylines is None or len(polylines) == 0: + map_data.pop(e) + logger.info(f"[filter] map element {e} is completely removed ") + continue + + is_inside = _polyline_inside_mask(polylines, center_xyz, dist_th_sq) + succ = _filter_polylines_and_labels(map_element, is_inside) + if not succ: + logger.info(f"[filter] map element {e} is completely removed ") + map_data.pop(e) + + return + + +def _polyline_labels( + label: object, n_polyline: int, device: torch.device +) -> torch.Tensor: + label_tensor = torch.as_tensor(label, dtype=torch.long, device=device).reshape(-1) + if label_tensor.numel() == 0: + raise ValueError("CATK map element has empty label") + if label_tensor.numel() == 1: + return label_tensor.expand(n_polyline) + if label_tensor.numel() < n_polyline: + logger.warning( + "CATK map element label count %s is smaller than " + "polyline count %s; using first label for the layer", + label_tensor.numel(), + n_polyline, + ) + return label_tensor[:1].expand(n_polyline) + if label_tensor.numel() > n_polyline: + logger.warning( + "CATK map element label count %s is larger than " + "polyline count %s; truncating labels", + label_tensor.numel(), + n_polyline, + ) + return label_tensor[:n_polyline] + + +def extract_map_data( + env_data, + device: str, + downsample_lines: bool, + disable_sub_plyline_type: bool, +) -> tuple[torch.Tensor | None, torch.Tensor | None, dict | None, dict | None]: + """Convert all EnvData map layers with polylines/label into CATK map tensors.""" + rb_polylines = [] + rb_polylines_batch = [] + polyline_triplets = [] + polyline_types = [] + polygon_types = [] + batch = [] + + b = 0 + for map_element in env_data["map"].values(): + if not isinstance(map_element, dict): + continue + + polylines = map_element.get("polylines") + label = map_element.get("label") + if polylines is None or label is None or len(polylines) == 0: + continue + if polylines.ndim != 3 or polylines.shape[1] < 3: + continue + + line_labels = _polyline_labels(label, polylines.shape[0], polylines.device) + road_boundary_mask = line_labels == POLYGON_TYPES_ROAD_BOUNDARIES + if road_boundary_mask.any(): + rb_polylines.append(polylines[road_boundary_mask]) + rb_polylines_batch.append(b) + + if downsample_lines: + if polylines.shape[1] < 11: + continue + triplet = polylines.unfold(dimension=1, size=11, step=10).transpose(-1, -2) + indices = torch.linspace(0, 10, steps=3).long() + flat_triplet_xy = triplet.flatten(0, 1)[:, indices, :2] + else: + triplet = polylines.unfold(dimension=1, size=3, step=1).transpose(-1, -2) + flat_triplet_xy = triplet.flatten(0, 1)[:, :, :2] + + n_windows = triplet.shape[1] + triplet_labels = line_labels.repeat_interleave(n_windows) + valid_mask = ~flat_triplet_xy.isnan().any(dim=-1).any(dim=-1) + flat_triplet_xy = flat_triplet_xy[valid_mask] + triplet_labels = triplet_labels[valid_mask.to(triplet_labels.device)] + + if flat_triplet_xy.shape[0] == 0: + continue + + polyline_triplets.append(flat_triplet_xy) + n_pl = flat_triplet_xy.shape[0] + batch.append(torch.full((n_pl,), b, dtype=torch.long, device=device)) + polyline_types.append(triplet_labels.to(device=device, dtype=torch.long)) + polygon_types.append(triplet_labels.to(device=device, dtype=torch.long)) + + if len(polyline_triplets) == 0: + return None, None, None, None + + triplets = torch.cat(polyline_triplets, dim=0).to(dtype=torch.float32) + triplet_thetas = torch.atan2( + triplets[:, 1, 1] - triplets[:, 0, 1], + triplets[:, 1, 0] - triplets[:, 0, 0], + ) + + triplets = triplets.to(device) + triplet_thetas = triplet_thetas.to(device) + polyline_types = torch.cat(polyline_types, dim=0).to(device) + if disable_sub_plyline_type: + polyline_types.fill_(0) + + polyline_extras = { + "type": polyline_types, + "pl_type": torch.cat(polygon_types, dim=0).to(device), + "light_type": torch.zeros_like(polyline_types), + "batch": torch.cat(batch, dim=0).to(device), + } + rb_data = {"rb_polylines": rb_polylines, "rb_polylines_batch": rb_polylines_batch} + return triplets, triplet_thetas, polyline_extras, rb_data + + +def extract_ego_data(env_data, t_beg: int, t_end: int, dt: float, device: str) -> dict: + """ + Args: + env_data: dict + 'agents' + 'ego' + t_beg: int + t_end: int + dt: float + device: str + + Returns: + data["agent"]: Dict + "role": [n_agent, 3], bool + "id": [n_agent], int64 + "type": [n_agent], uint8 + "valid_mask": [n_agent, n_step], bool + "position": [n_agent, n_step, 3], float32 + "heading": [n_agent, n_step], float32 + "velocity": [n_agent, n_step, 2], float32 + "shape": [n_agent, 3], float32 + "batch": [n_agent], int64 + "num_obstacle" + """ + + target_steps = max(t_end - t_beg, 0) + ego_xyz = env_data["ego"]["xyz"][t_beg:t_end].clone().to(device) + ego_heading = env_data["ego"]["heading"][t_beg:t_end].clone().to(device) + if ego_xyz.shape[0] == 0: + fallback_xyz = env_data["ego"]["xyz"][-1:].clone().to(device) + fallback_heading = env_data["ego"]["heading"][-1:].clone().to(device) + else: + finite_heading = torch.isfinite(ego_heading) + if finite_heading.any(): + fallback_idx = int(torch.nonzero(finite_heading, as_tuple=False)[-1].item()) + else: + fallback_idx = ego_heading.shape[0] - 1 + fallback_xyz = ego_xyz[fallback_idx : fallback_idx + 1].clone() + fallback_heading = ego_heading[fallback_idx : fallback_idx + 1].clone() + if not finite_heading.all(): + ego_xyz[~finite_heading] = fallback_xyz + ego_heading[~finite_heading] = fallback_heading + + if ego_xyz.shape[0] < target_steps: + pad_steps = target_steps - ego_xyz.shape[0] + ego_xyz = torch.cat([ego_xyz, fallback_xyz.repeat(pad_steps, 1)], dim=0) + ego_heading = torch.cat( + [ego_heading, fallback_heading.repeat(pad_steps)], dim=0 + ) + + # 1,T,D + ego_xyz = ego_xyz.unsqueeze(0) + # 1,T + ego_heading = ego_heading.unsqueeze(0) + # 1,3 + ego_lwh = env_data["ego"]["lwh"].clone().to(device).unsqueeze(0) + + ego_role = torch.ones((1, 3), dtype=torch.bool, device=device) + ego_type = torch.zeros((1), dtype=torch.long, device=device) + ego_valid_mask = ~torch.isnan(ego_heading) + ego_id = torch.zeros((1,), dtype=torch.long, device=device) + + # concate ego and agents + agent_data = {} + agent_data["valid_mask"] = ego_valid_mask + agent_data["role"] = ego_role + agent_data["type"] = ego_type + agent_data["id"] = ego_id + + # (n,t,3) + agent_data["position"] = ego_xyz + agent_data["heading"] = ego_heading + agent_data["shape"] = ego_lwh + + if agent_data["position"].shape[1] <= 1: + velocity = torch.zeros( + (1, agent_data["position"].shape[1], 2), + dtype=agent_data["position"].dtype, + device=device, + ) + else: + velocity = ( + agent_data["position"][:, 1:, :2] - agent_data["position"][:, :-1, :2] + ) / dt + velocity = torch.cat([velocity, velocity[:, -1:]], dim=1) + + agent_data["velocity"] = velocity + agent_data["batch"] = torch.zeros((1,), dtype=torch.long, device=device) + + return agent_data + + +def extract_static_agent_freeze_data( + env_data, + *, + static_mask: torch.Tensor, + curr_t: int, + target_steps: int, + device: str, +) -> dict | None: + if not bool(static_mask.any().item()): + return None + + source_device = env_data["agents"]["xyz"].device + static_indices = torch.where(static_mask.to(device=source_device))[0] + current_xyz = ( + env_data["agents"]["xyz"][static_indices, curr_t, :].clone().to(device) + ) + current_heading = ( + env_data["agents"]["heading"][static_indices, curr_t].clone().to(device) + ) + current_valid = ( + env_data["agents"]["valid_mask"][static_indices, curr_t].clone().to(device) + ) + n_static = int(static_indices.numel()) + + agent_data = { + "valid_mask": current_valid.unsqueeze(1).expand(-1, target_steps).clone(), + "position": current_xyz.unsqueeze(1).expand(-1, target_steps, -1).clone(), + "heading": current_heading.unsqueeze(1).expand(-1, target_steps).clone(), + "shape": env_data["agents"]["lwh"][static_indices].clone().to(device), + "id": env_data["agents"]["track_ids"][static_indices].clone().to(device).long(), + "batch": torch.zeros((n_static,), dtype=torch.long, device=device), + "velocity": torch.zeros( + (n_static, target_steps, 2), + dtype=current_xyz.dtype, + device=device, + ), + } + agent_data["role"] = torch.zeros((n_static, 3), dtype=torch.bool, device=device) + agent_data["role"][:, 1] = True + agent_data["role"][:, 2] = True + agent_data["type"] = agent_type_from_class_ids( + env_data, + env_data["agents"]["class_ids"][static_indices].clone().to(device), + device, + ) + return agent_data + + +def static_agent_freeze_mask( + env_data, *, n_agent: int, curr_t: int, device: str +) -> torch.Tensor: + raw_mask = env_data["env"].get("agent_is_static") + if raw_mask is None or n_agent <= 0: + return torch.zeros((n_agent,), dtype=torch.bool, device=device) + + static_mask = torch.as_tensor(raw_mask, dtype=torch.bool, device=device).flatten() + if int(static_mask.numel()) < n_agent: + padded = torch.zeros((n_agent,), dtype=torch.bool, device=device) + padded[: int(static_mask.numel())] = static_mask + static_mask = padded + else: + static_mask = static_mask[:n_agent] + + valid_now = env_data["agents"]["valid_mask"][:n_agent, curr_t].to(device=device) + return static_mask & valid_now + + +def _concat_agent_data(first: dict | None, second: dict) -> dict: + if first is None: + return second + return {key: torch.cat([first[key], second[key]], dim=0) for key in second} + + +def build_freeze_agent_data( + env_data, + *, + curr_t: int, + target_steps: int, + dt: float, + device: str, +) -> tuple[BatchDict, torch.Tensor]: + n_agent = int(env_data["agents"]["num_obstacles"]) + static_mask = static_agent_freeze_mask( + env_data, + n_agent=n_agent, + curr_t=curr_t, + device=device, + ) + freeze_mask = torch.zeros((n_agent + 1,), dtype=torch.bool, device=device) + freeze_mask[:n_agent] = static_mask + freeze_mask[-1] = True + + static_data = extract_static_agent_freeze_data( + env_data, + static_mask=static_mask, + curr_t=curr_t, + target_steps=target_steps, + device=device, + ) + ego_data = extract_ego_data( + env_data, + t_beg=curr_t, + t_end=curr_t + target_steps, + dt=dt, + device=device, + ) + freeze_data = BatchDict( + { + "agent": _concat_agent_data(static_data, ego_data), + "num_obstacles": torch.tensor( + (int(freeze_mask.sum().item()),), + device=device, + dtype=torch.long, + ).reshape(1), + "num_graphs": 1, + } + ) + return freeze_data, freeze_mask + + +def extract_agents_and_ego_data( + env_data, + t_beg: int, + t_end: int, + dt: float, + device: str, + avoid_fragmented_agents: bool = False, +) -> dict: + """ + Args: + env_data: dict + 'agents': T=16,16+X,16+X*2, ... + 'xyz': torch.Tensor, # N,T,3 + 'heading': torch.Tensor, # N,T + 'valid_mask': torch.Tensor, # N,T, + 'lwh': torch.Tensor, # N,3 + 'track_ids': torch.Tensor, # N + 'class_ids': torch.Tensor, # N + 'num_obstacles': torch.Tensor, # 1, + # 'timestamps': torch.Tensor, # T, (us) + 'ego' + t_beg: int + t_end: int + dt: float + device: str + + + Returns: + data["agent"]: Dict + "role": [n_agent, 3], bool + "id": [n_agent], int64 + "type": [n_agent], uint8 + "valid_mask": [n_agent, n_step], bool + "position": [n_agent, n_step, 3], float32 + "heading": [n_agent, n_step], float32 + "velocity": [n_agent, n_step, 2], float32 + "shape": [n_agent, 3], float32 + "batch": [n_agent], int64 + "num_obstacle" [n_agent] + """ + + n_agent = env_data["agents"]["num_obstacles"] + + agent_xyz = env_data["agents"]["xyz"][:n_agent, t_beg:t_end].clone().to(device) + agent_heading = ( + env_data["agents"]["heading"][:n_agent, t_beg:t_end].clone().to(device) + ) + agent_lwh = env_data["agents"]["lwh"][:n_agent].clone().to(device) + + agent_valid_mask = ( + env_data["agents"]["valid_mask"][:n_agent, t_beg:t_end].clone().to(device) + ) + if avoid_fragmented_agents: + agent_valid = agent_valid_mask.sum(dim=1) + agent_valid_mask[agent_valid < 2] = False + agent_curr_valid = agent_valid_mask[:, -1] + agent_valid_mask[~agent_curr_valid] = False + + agent_role = torch.zeros((n_agent, 3), dtype=torch.bool, device=device) + agent_role[:, 0] = False # ego_vehicle + agent_role[:, 1] = True # interest + agent_role[:, 2] = True # predict + + agent_id = env_data["agents"]["track_ids"][:n_agent].clone().to(device) + agent_id = agent_id.to(torch.long) + agent_class_ids = env_data["agents"]["class_ids"][:n_agent].clone().to(device) + + agent_type = agent_type_from_class_ids(env_data, agent_class_ids, device) + + # 1,T,D + ego_xyz = env_data["ego"]["xyz"][t_beg:t_end].clone().to(device).unsqueeze(0) + # 1,T + ego_heading = ( + env_data["ego"]["heading"][t_beg:t_end].clone().to(device).unsqueeze(0) + ) + # 1,3 + ego_lwh = env_data["ego"]["lwh"].clone().to(device).unsqueeze(0) + + ego_role = torch.ones((1, 3), dtype=torch.bool, device=device) + ego_type = torch.zeros((1), dtype=torch.long, device=device) + ego_valid_mask = ~torch.isnan(ego_heading) + + ego_id = torch.zeros((1,), dtype=torch.long, device=device) + + # concate ego and agents + agent_data = {} + agent_data["valid_mask"] = torch.cat([agent_valid_mask, ego_valid_mask], dim=0) + agent_data["role"] = torch.cat([agent_role, ego_role], dim=0) + agent_data["type"] = torch.cat([agent_type, ego_type], dim=0) + agent_data["id"] = torch.cat([agent_id, ego_id], dim=0) + + # (n,t,3) + agent_data["position"] = torch.cat([agent_xyz, ego_xyz], dim=0) + agent_data["heading"] = torch.cat([agent_heading, ego_heading], dim=0) + agent_data["shape"] = torch.cat([agent_lwh, ego_lwh], dim=0) + + # clean NaN + agent_data["position"] = torch.nan_to_num(agent_data["position"]) + agent_data["heading"] = torch.nan_to_num(agent_data["heading"]) + + velocity = ( + agent_data["position"][:, 1:, :2] - agent_data["position"][:, :-1, :2] + ) / dt + velocity = torch.cat([velocity, velocity[:, -1:]], dim=1) + + agent_data["velocity"] = velocity + + agent_data["batch"] = torch.zeros((n_agent + 1,), dtype=torch.long, device=device) + + return agent_data diff --git a/src/trafficsim/alpasim_trafficsim/catk/map_adapter.py b/src/trafficsim/alpasim_trafficsim/catk/map_adapter.py new file mode 100644 index 00000000..30fd6ece --- /dev/null +++ b/src/trafficsim/alpasim_trafficsim/catk/map_adapter.py @@ -0,0 +1,303 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation + +import math + +import numpy as np +import torch +from alpasim_utils.geometry import Polyline +from trajdata.maps.vec_map_elements import MapElementType + +MAP_ELEMENT_NAME2_TYPEID = { + "lane_lines": 0, + "road_boundaries": 1, + "wait_lines": 2, + "crosswalks": 3, + "lane_boundaries": 4, + "lane_centers": 5, + "intersection_areas": 6, + "road_islands": 7, + "road_markings": 8, + "poles": 9, + "traffic_signs": 10, +} + +ENVDATA_MAP_KEY_TO_CATK_MAP_ELEMENT_NAME = { + "lanelines": "lane_lines", + "road_boundaries": "road_boundaries", + "waitlines": "wait_lines", + "lane_boundaries": "lane_boundaries", + "lane_centers": "lane_centers", + "crosswalks": "crosswalks", + "intersection_areas": "intersection_areas", + "road_islands": "road_islands", +} + + +def build_env_map_from_vector_map( + vector_map, + *, + ego_xyz: torch.Tensor, + ego_heading: torch.Tensor | float, + distance_x: float, + distance_y: float, + map_polyline_length_k: int, + map_resample_interval_m: float | None = None, +) -> dict: + """Adapt a trajdata VectorMap to the current runtime map contract.""" + raw_layers = _vector_map_polylines_by_env_key(vector_map) + env_map: dict = {} + for ( + env_key, + catk_map_element_name, + ) in ENVDATA_MAP_KEY_TO_CATK_MAP_ELEMENT_NAME.items(): + env_map[env_key] = _build_env_map_element( + _map_element_from_arrays( + raw_layers.get(env_key, []), + map_resample_interval_m=map_resample_interval_m, + ), + catk_map_element_name=catk_map_element_name, + ego_xyz=ego_xyz, + ego_heading=ego_heading, + distance_x=distance_x, + distance_y=distance_y, + map_polyline_length_k=map_polyline_length_k, + ) + return env_map + + +def _vector_map_polylines_by_env_key(vector_map) -> dict[str, list[np.ndarray]]: + layers: dict[str, list[np.ndarray]] = { + "lanelines": [], + "road_boundaries": [], + "waitlines": [], + "lane_boundaries": [], + "lane_centers": [], + "crosswalks": [], + "intersection_areas": [], + "road_islands": [], + } + + for lane in vector_map.elements.get(MapElementType.ROAD_LANE, {}).values(): + layers["lane_centers"].append(lane.center.xyz) + for edge in (lane.left_edge, lane.right_edge): + if edge is None: + continue + layers["lane_boundaries"].append(edge.xyz) + # VectorMap does not distinguish lane markings from lane edges for + # all sources. Preserve the CATK laneline layer from the available + # lane-edge geometry. + layers["lanelines"].append(edge.xyz) + + for road_edge in vector_map.elements.get(MapElementType.ROAD_EDGE, {}).values(): + layers["road_boundaries"].append(road_edge.polyline.xyz) + + for wait_line in vector_map.elements.get(MapElementType.WAIT_LINE, {}).values(): + layers["waitlines"].append(wait_line.polyline.xyz) + + for crosswalk in vector_map.elements.get(MapElementType.PED_CROSSWALK, {}).values(): + layers["crosswalks"].append(crosswalk.polygon.xyz) + + for road_area in vector_map.elements.get(MapElementType.ROAD_AREA, {}).values(): + layers["intersection_areas"].append(road_area.exterior_polygon.xyz) + for hole in road_area.interior_holes: + layers["road_islands"].append(hole.xyz) + + return layers + + +def _map_element_from_arrays( + polylines: list[np.ndarray], + *, + map_resample_interval_m: float | None, +) -> dict | None: + if not polylines: + return None + cleaned = [] + for polyline in polylines: + arr = np.asarray(polyline, dtype=np.float32) + if arr.ndim != 2 or arr.shape[0] < 2 or arr.shape[1] < 2: + continue + if arr.shape[1] == 2: + arr = np.concatenate( + [arr, np.zeros((arr.shape[0], 1), dtype=np.float32)], + axis=1, + ) + if map_resample_interval_m is not None: + arr = _resample_polyline_by_interval(arr, map_resample_interval_m) + cleaned.append(arr[..., :3]) + if not cleaned: + return None + max_vertices = max(polyline.shape[0] for polyline in cleaned) + stacked = np.full((len(cleaned), max_vertices, 3), np.nan, dtype=np.float32) + for idx, polyline in enumerate(cleaned): + stacked[idx, : polyline.shape[0], :] = polyline + return {"polylines": torch.from_numpy(stacked)} + + +def _resample_polyline_by_interval( + polyline: np.ndarray, + interval_m: float, +) -> np.ndarray: + if interval_m <= 0: + raise ValueError(f"interval_m must be positive, got {interval_m}") + return ( + Polyline(polyline) + .resample_by_spacing(interval_m) + .waypoints.astype( + np.float32, + copy=False, + ) + ) + + +def _build_env_map_element( + map_element: dict | None, + *, + catk_map_element_name: str, + ego_xyz: torch.Tensor, + ego_heading: torch.Tensor | float, + distance_x: float, + distance_y: float, + map_polyline_length_k: int, +) -> dict | None: + if not isinstance(map_element, dict): + return None + + polylines = map_element.get("polylines") + if not torch.is_tensor(polylines) or polylines.numel() == 0: + return None + + fixed_polylines = [] + for polyline in polylines: + finite_polyline = _finite_polyline_vertices(polyline) + if finite_polyline is None: + continue + if not _polyline_hits_local_box( + finite_polyline, + ego_xyz=ego_xyz, + ego_heading=ego_heading, + distance_x=distance_x, + distance_y=distance_y, + ): + continue + fixed_polylines.append( + _build_map_polyline_segments(finite_polyline, map_polyline_length_k) + ) + + if not fixed_polylines: + return None + + stacked = torch.cat(fixed_polylines, dim=0).to(dtype=torch.float32) + if stacked.shape[0] == 0: + return None + label = torch.full( + (stacked.shape[0],), + MAP_ELEMENT_NAME2_TYPEID[catk_map_element_name], + dtype=torch.long, + device=stacked.device, + ) + return { + "polylines": stacked, + "label": label, + "polylines_styles": None, + "polylines_colors": None, + } + + +def _finite_polyline_vertices(polyline: torch.Tensor) -> torch.Tensor | None: + if polyline.ndim != 2 or polyline.shape[-1] < 3: + return None + finite_mask = torch.isfinite(polyline).all(dim=-1) + finite_polyline = polyline[finite_mask, :3].clone() + if finite_polyline.shape[0] < 2: + return None + + deltas = finite_polyline[1:] - finite_polyline[:-1] + keep_mask = torch.cat( + [ + torch.ones((1,), dtype=torch.bool, device=finite_polyline.device), + torch.linalg.norm(deltas, dim=-1) > 1e-6, + ] + ) + finite_polyline = finite_polyline[keep_mask] + if finite_polyline.shape[0] < 2: + return None + return finite_polyline + + +def _build_map_polyline_segments( + polyline: torch.Tensor, map_polyline_length_k: int +) -> torch.Tensor: + """Segment map polylines while keeping EnvData layers.""" + if map_polyline_length_k < 1: + raise ValueError( + f"map_polyline_length_k must be >= 1, got {map_polyline_length_k}" + ) + if map_polyline_length_k > 1: + polyline = _downsample_polyline_vertices(polyline, factor=map_polyline_length_k) + return _overlapping_three_point_segments(polyline) + + +def _downsample_polyline_vertices( + polyline: torch.Tensor, + *, + factor: int, +) -> torch.Tensor: + if factor <= 1 or polyline.shape[0] <= 1: + return polyline + indices = torch.arange(0, polyline.shape[0], factor, device=polyline.device) + last_idx = polyline.shape[0] - 1 + if int(indices[-1].item()) != last_idx: + indices = torch.cat([indices, indices.new_tensor([last_idx])]) + return polyline[indices] + + +def _overlapping_three_point_segments(polyline: torch.Tensor) -> torch.Tensor: + if polyline.shape[0] < 3: + return polyline.new_empty((0, 3, 3)) + + starts = torch.arange(0, polyline.shape[0] - 2, 2, device=polyline.device) + tail_start = polyline.shape[0] - 3 + if int(starts[-1].item()) != tail_start: + starts = torch.cat([starts, starts.new_tensor([tail_start])]) + + offsets = torch.arange(3, device=polyline.device) + return polyline[starts[:, None] + offsets[None, :]] + + +def _polyline_hits_local_box( + polyline: torch.Tensor, + *, + ego_xyz: torch.Tensor, + ego_heading: torch.Tensor | float, + distance_x: float, + distance_y: float, +) -> bool: + if distance_x <= 0 or distance_y <= 0: + return True + + local_xy = _world_xy_to_local( + polyline[:, :2], + ego_xyz=ego_xyz.to(device=polyline.device, dtype=polyline.dtype), + ego_heading=ego_heading, + ) + inside_x = (local_xy[:, 0] >= -distance_x) & (local_xy[:, 0] <= distance_x) + inside_y = (local_xy[:, 1] >= -distance_y) & (local_xy[:, 1] <= distance_y) + return bool((inside_x & inside_y).any().item()) + + +def _world_xy_to_local( + xy: torch.Tensor, + *, + ego_xyz: torch.Tensor, + ego_heading: torch.Tensor | float, +) -> torch.Tensor: + heading = float(ego_heading) + rot = xy.new_tensor( + [ + [math.cos(heading), -math.sin(heading)], + [math.sin(heading), math.cos(heading)], + ] + ) + return (xy - ego_xyz[:2]) @ rot diff --git a/src/trafficsim/alpasim_trafficsim/catk/model_adapter.py b/src/trafficsim/alpasim_trafficsim/catk/model_adapter.py new file mode 100644 index 00000000..af11668a --- /dev/null +++ b/src/trafficsim/alpasim_trafficsim/catk/model_adapter.py @@ -0,0 +1,220 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation + +import os +from typing import Any, Dict + +import torch +from alpasim_trafficsim.catk.env_data_adapter import ( + build_freeze_agent_data, + extract_agents_and_ego_data, + extract_map_data, + filter_map, + load_model_config, +) +from alpasim_trafficsim.catk.smart import SMART +from loguru import logger + + +def _extract_model_config(config: dict[str, Any]) -> dict[str, Any]: + if "model_config" in config: + return config["model_config"] + if "model" in config and "model_config" in config["model"]: + return config["model"]["model_config"] + raise KeyError("CATK config must contain either model_config or model.model_config") + + +def _token_path(token_pkl_dir: str, token_file: str) -> str: + return os.path.abspath(os.path.join(token_pkl_dir, os.path.basename(token_file))) + + +class _BatchDict(dict): + def __getattr__(self, name: str) -> Any: + try: + return self[name] + except KeyError as exc: + raise AttributeError(name) from exc + + +class CATK: + def __init__( + self, + config_path: str, + ckpt_path: str, + token_pkl_dir: str, + disable_sub_plyline_type: bool, + device: str, + use_downsampled_lines: bool = False, + ): + self.model_config = _extract_model_config(load_model_config(config_path)) + self.ckpt_path = ckpt_path + self.device = device + + self.model_input_step_num = self.model_config["decoder"]["num_historical_steps"] + self.model_predict_step_num = 5 + self.delta_t = self.model_config["token_processor"]["time_step"] + + map_token_filename = self.model_config["token_processor"]["map_token_file"] + agent_token_filename = self.model_config["token_processor"]["agent_token_file"] + + self.model_config["token_processor"]["map_token_file"] = _token_path( + token_pkl_dir, map_token_filename + ) + self.model_config["token_processor"]["agent_token_file"] = _token_path( + token_pkl_dir, agent_token_filename + ) + + self.model = SMART(self.model_config).to(self.device) + self.model.eval() + + state_dict = torch.load( + self.ckpt_path, map_location=self.device, weights_only=False + )["state_dict"] + self.model.load_state_dict(state_dict, strict=False) + + self.disable_sub_plyline_type = disable_sub_plyline_type + self.use_downsampled_lines = use_downsampled_lines + + def create_model_input( + self, + env_data: dict[str, Any], + filter_map_by_ego: bool, + filter_distance_th: float, + ) -> dict | None: + ego_xy = env_data["ego"]["xyz"][env_data["env"]["curr_t"]] + if filter_map_by_ego and filter_distance_th > 0: + filter_map( + env_data=env_data, center_xyz=ego_xy, distance_th=filter_distance_th + ) + + input_data = { + "map": {}, + "agent": {}, + "num_graphs": 1, + } + + input_step_num = self.model_input_step_num + + curr_t = env_data["env"]["curr_t"] + t_end = curr_t + 1 + t_beg = t_end - input_step_num + + assert ( + t_beg >= 0 + ), f"not enough history and current data for model ({curr_t + 1})" + + logger.info(f"extract ego and agent data in t: [{t_beg}, {t_end}]") + + input_data["agent"] = extract_agents_and_ego_data( + env_data, + t_beg=t_beg, + t_end=t_end, + dt=self.delta_t, + device=self.device, + ) + + assert curr_t >= 1 + logger.info( + f"extract frozen agent data in t: [{curr_t}, {curr_t + 1 + self.model_predict_step_num}]" + ) + freeze_agent_data, freeze_agent_mask = build_freeze_agent_data( + env_data, + curr_t=curr_t, + target_steps=1 + self.model_predict_step_num, + dt=self.delta_t, + device=self.device, + ) + input_data["freeze_agent_data"] = freeze_agent_data + input_data["freeze_agent_mask"] = freeze_agent_mask + + triplets, triplet_thetas, polyline_extras, rb_data = extract_map_data( + env_data, + device=self.device, + downsample_lines=self.use_downsampled_lines, + disable_sub_plyline_type=self.disable_sub_plyline_type, + ) + if triplets is None: + logger.warning("CATK model input skipped because no map data is available") + return None + + input_data["map"]["triplets"] = triplets + input_data["map"]["triplet_thetas"] = triplet_thetas + input_data["map"]["polyline_extras"] = polyline_extras + input_data["map"]["rb_data"] = rb_data + + logger.info(f"[proxy] triplets #:{triplets.shape[0]}") + return {"input_data": input_data} + + def inference(self, input_data: Dict[str, Any]): + self.model.encoder.agent_encoder.num_future_steps = self.model_predict_step_num + + sampling_scheme = self.model.validation_rollout_sampling + step_current_10hz = self.model.encoder.agent_encoder.num_historical_steps # 10 + dt = self.delta_t + + with torch.no_grad(): + state = _BatchDict( + { + "position": None, + "agent": input_data["agent"], + "num_obstacles": torch.tensor( + (input_data["agent"]["id"].shape[0],), + device=self.device, + dtype=torch.long, + ).reshape(1), + "traj_pos": None, + "traj_theta": None, + "map_save": { + "traj_pos": input_data["map"]["triplets"], + "traj_theta": input_data["map"]["triplet_thetas"], + }, + "pt_token": input_data["map"]["polyline_extras"], + "rb_data": input_data["map"]["rb_data"], + "num_graphs": input_data["num_graphs"], + } + ) + tokenized_map, tokenized_agent, _, _ = self.model.token_processor( + state, + apply_heading_correction=True, + apply_boundary_extrapolation=True, + ) + map_feature = self.model.encoder.map_encoder(tokenized_map) + + is_ego = tokenized_agent["ego_mask"] + + # Tokenized known future for ego and static agents. + fz_tokenized_agent, _ = self.model.token_processor.tokenize_agent( + input_data["freeze_agent_data"], + apply_heading_correction=True, + apply_boundary_extrapolation=True, + ) + + output = self.model.encoder.agent_encoder.inference_with_mask( + tokenized_agent=tokenized_agent, + map_feature=map_feature, + sampling_scheme=sampling_scheme, + freeze_agent_future=True, + freeze_agent_mask=input_data["freeze_agent_mask"], + freeze_tokenized_agent=fz_tokenized_agent, + ) + + future_xyz = torch.cat( + [output["pred_traj_10hz"], output["pred_z_10hz"].unsqueeze(-1)], + dim=-1, + ) + future_heading = output["pred_head_10hz"] + future_valid = output["pred_valid_10hz"] + + pos_hist = state["agent"]["position"][:, :step_current_10hz] + last_hist_pos = pos_hist[:, -1:, :] + pos_all = torch.cat([last_hist_pos, future_xyz], dim=1) + future_velocity = (pos_all[:, 1:] - pos_all[:, :-1]) / dt + + pnum = self.model_predict_step_num + actions = { + "agent_future_xyz": future_xyz[~is_ego, :pnum], + "agent_future_heading": future_heading[~is_ego, :pnum], + "agent_future_valid_mask": future_valid[~is_ego, :pnum], + "agent_future_velocity": future_velocity[~is_ego, :pnum, :2], + } + return actions diff --git a/src/trafficsim/alpasim_trafficsim/catk/obstacle_classes.py b/src/trafficsim/alpasim_trafficsim/catk/obstacle_classes.py new file mode 100644 index 00000000..ebff5b6a --- /dev/null +++ b/src/trafficsim/alpasim_trafficsim/catk/obstacle_classes.py @@ -0,0 +1,25 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation + +from types import MappingProxyType +from typing import Final + +OBSTACLE_CLASS_NAME_TO_ID: Final = MappingProxyType( + { + "car": 0, + "truck": 1, + "pedestrian": 2, + "cyclist": 3, + "others": 4, + } +) +OBSTACLE_CLASS_ID_TO_NAME: Final = MappingProxyType( + {class_id: name for name, class_id in OBSTACLE_CLASS_NAME_TO_ID.items()} +) + + +def obstacle_class_metadata() -> dict: + return { + "obstacle_class_name_2_id": dict(OBSTACLE_CLASS_NAME_TO_ID), + "obstacle_class_id_2_name": dict(OBSTACLE_CLASS_ID_TO_NAME), + } diff --git a/src/trafficsim/alpasim_trafficsim/catk/scene_adapter.py b/src/trafficsim/alpasim_trafficsim/catk/scene_adapter.py new file mode 100644 index 00000000..080f2ce6 --- /dev/null +++ b/src/trafficsim/alpasim_trafficsim/catk/scene_adapter.py @@ -0,0 +1,543 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation + +import copy +from pathlib import Path + +import numpy as np +import torch +from alpasim_trafficsim.catk.map_adapter import ( + ENVDATA_MAP_KEY_TO_CATK_MAP_ELEMENT_NAME, + MAP_ELEMENT_NAME2_TYPEID, + build_env_map_from_vector_map, +) +from alpasim_trafficsim.catk.obstacle_classes import ( + OBSTACLE_CLASS_NAME_TO_ID, + obstacle_class_metadata, +) + +# Runtime map layers that are not currently provided by the VectorMap adapter +# are null-filled so downstream consumers can rely on stable map keys. +_MAP_NULL_KEYS = ( + "poles", + "traffic_signs", + "traffic_lights", + "nds_polygons", + "road_markings", +) + + +class CATKSceneAdapter: + """Convert artifact-backed scenes into CATK runtime dict state. + + Runtime uses a plain dict state with the existing keys expected by simulation + and CATK prediction write-back. Scene content comes from the shared + ``Artifact``/``SceneDataSource`` abstraction instead of reading scene files + directly in trafficsim. + """ + + def __init__( + self, + num_history_steps: int = 16, + motion_stepsize: float = 0.1, + map_distance_x: float = 0, + map_distance_y: float = 0, + map_polyline_length_k: int = 1, + map_resample_interval_m: float | None = 1.0, + cache_size: int | None = None, + ): + if map_resample_interval_m is not None and map_resample_interval_m <= 0: + raise ValueError( + "map_resample_interval_m must be positive when provided, " + f"got {map_resample_interval_m}" + ) + self.motion_stepsize = motion_stepsize + self._num_history_steps = num_history_steps + self._map_distance_x = map_distance_x + self._map_distance_y = map_distance_y + self._map_polyline_length_k = map_polyline_length_k + self._map_resample_interval_m = map_resample_interval_m + self._cache: dict[str, dict] = {} + self._cache_size = cache_size + + def load(self, data_source) -> dict: + """Load CATK env data from an artifact-like scene data source. + + The generic SceneDataSource protocol exposes rig, traffic object, map, + and metadata properties. The CATK adapter converts those scene-level + abstractions into the mutable tensor dict used by the service. + """ + if not all( + hasattr(data_source, attr) + for attr in ("rig", "traffic_objects", "map", "metadata") + ): + raise TypeError( + "CATKSceneAdapter requires a scene data source exposing rig, " + "traffic_objects, map, and metadata." + ) + source_path = getattr(data_source, "source", "") + scene_id = str(getattr(data_source, "scene_id", Path(str(source_path)).stem)) + cache_key = f"{scene_id}:{source_path}" + if cache_key in self._cache: + return copy.deepcopy(self._cache[cache_key]) + + env_data = self._build_env_data_from_artifact(data_source, scene_id=scene_id) + self._remember(cache_key, env_data) + return copy.deepcopy(env_data) + + def _remember(self, cache_key: str, env_data: dict) -> None: + if self._cache_size == 0: + return + if self._cache_size is not None and len(self._cache) >= self._cache_size: + oldest_key = next(iter(self._cache)) + self._cache.pop(oldest_key) + self._cache[cache_key] = env_data + + def _build_env_data_from_artifact( + self, + artifact, + scene_id: str | None = None, + ) -> dict: + curr_t = self._num_history_steps - 1 + dt_us = int(round(self.motion_stepsize * 1_000_000)) + timestamps_us = _regular_timestamps_us(artifact.rig.trajectory, dt_us=dt_us) + curr_t = min(curr_t, len(timestamps_us) - 1) + t0_us = int(timestamps_us[curr_t]) + + ego = _build_ego_from_rig(artifact.rig, timestamps_us) + map_data = _build_map( + artifact.map, + ego=ego, + curr_t=curr_t, + distance_x=self._map_distance_x, + distance_y=self._map_distance_y, + map_polyline_length_k=self._map_polyline_length_k, + map_resample_interval_m=self._map_resample_interval_m, + ) + + final_scene_id = ( + scene_id if scene_id is not None else artifact.metadata.scene_id + ) + + metadata = { + **obstacle_class_metadata(), + "scene_id": final_scene_id, + "frame_rate": int(1.0 / self.motion_stepsize), + "t0_us": t0_us, + "map_source": "trajdata_vector_map", + } + + agents, agent_object_ids, agent_is_static = _build_agents_from_traffic_objects( + artifact.traffic_objects, + timestamps_us, + ) + + env_key = { + "curr_t": curr_t, + "frame_rate": int(1.0 / self.motion_stepsize), + "agent_object_ids": agent_object_ids, + "agent_is_static": agent_is_static, + } + + return { + "metadata": metadata, + "map": map_data, + "agents": agents, + "ego": ego, + "env": env_key, + } + + +def _build_map( + vector_map, + *, + ego: dict, + curr_t: int, + distance_x: float, + distance_y: float, + map_polyline_length_k: int, + map_resample_interval_m: float | None, +) -> dict: + """Build runtime map layers from a trajdata VectorMap.""" + if vector_map is None: + env_map = { + env_key: None for env_key in ENVDATA_MAP_KEY_TO_CATK_MAP_ELEMENT_NAME + } + else: + env_map = build_env_map_from_vector_map( + vector_map, + ego_xyz=ego["xyz"][curr_t], + ego_heading=ego["heading"][curr_t], + distance_x=distance_x, + distance_y=distance_y, + map_polyline_length_k=map_polyline_length_k, + map_resample_interval_m=map_resample_interval_m, + ) + + for key in _MAP_NULL_KEYS: + env_map[key] = None + + return env_map + + +def _regular_timestamps_us(trajectory, *, dt_us: int) -> np.ndarray: + timestamps = np.asarray(trajectory.timestamps_us, dtype=np.int64) + if timestamps.size == 0: + raise ValueError("Artifact rig trajectory has no timestamps") + start_us = int(timestamps[0]) + end_us = int(timestamps[-1]) + if end_us <= start_us: + end_us = int(timestamps.max()) + if end_us <= start_us: + return np.asarray([start_us], dtype=np.int64) + steps = int(np.floor((end_us - start_us) / dt_us)) + 1 + return start_us + (np.arange(steps, dtype=np.int64) * dt_us) + + +def _trajectory_pose_tensors( + trajectory, timestamps_us: np.ndarray +) -> tuple[torch.Tensor, torch.Tensor]: + xyz = torch.zeros((len(timestamps_us), 3), dtype=torch.float32) + heading = torch.zeros((len(timestamps_us),), dtype=torch.float32) + for idx, timestamp_us in enumerate(timestamps_us): + pose = trajectory.interpolate_pose(int(timestamp_us)) + xyz[idx] = torch.as_tensor(pose.vec3, dtype=torch.float32) + heading[idx] = float(pose.yaw()) + return xyz, heading + + +def _build_ego_from_rig(rig, timestamps_us: np.ndarray) -> dict: + xyz, heading = _trajectory_pose_tensors(rig.trajectory, timestamps_us) + vehicle_config = rig.vehicle_config + if vehicle_config is None: + lwh = torch.tensor([5.393, 2.109, 1.503], dtype=torch.float32) + else: + lwh = torch.tensor( + [ + float(vehicle_config.aabb_x_m), + float(vehicle_config.aabb_y_m), + float(vehicle_config.aabb_z_m), + ], + dtype=torch.float32, + ) + return { + "xyz": xyz, + "heading": heading, + "lwh": lwh, + } + + +def _build_agents_from_traffic_objects( + traffic_objects, + timestamps_us: np.ndarray, +) -> tuple[dict, list[str], list[bool]]: + if not traffic_objects: + return _build_empty_agents_for_steps(len(timestamps_us)), [], [] + + objects = list(traffic_objects.values()) + num_agents = len(objects) + num_steps = len(timestamps_us) + xyz = torch.zeros((num_agents, num_steps, 3), dtype=torch.float32) + heading = torch.zeros((num_agents, num_steps), dtype=torch.float32) + valid_mask = torch.zeros((num_agents, num_steps), dtype=torch.bool) + lwh = torch.zeros((num_agents, 3), dtype=torch.float32) + track_ids = torch.zeros((num_agents,), dtype=torch.long) + class_ids = torch.zeros((num_agents,), dtype=torch.long) + agent_object_ids: list[str] = [] + agent_is_static: list[bool] = [] + + for agent_idx, traffic_object in enumerate(objects): + agent_object_ids.append(str(traffic_object.track_id)) + agent_is_static.append(bool(traffic_object.is_static)) + track_ids[agent_idx] = _track_id_as_int(traffic_object.track_id, agent_idx) + class_ids[agent_idx] = _class_id_from_label(traffic_object.label_class) + lwh[agent_idx] = torch.tensor( + [ + float(traffic_object.aabb.x), + float(traffic_object.aabb.y), + float(traffic_object.aabb.z), + ], + dtype=torch.float32, + ) + + time_range = traffic_object.trajectory.time_range_us + for step_idx, timestamp_us in enumerate(timestamps_us): + if int(timestamp_us) < int(time_range.start) or int(timestamp_us) >= int( + time_range.stop + ): + continue + pose = traffic_object.trajectory.interpolate_pose(int(timestamp_us)) + xyz[agent_idx, step_idx] = torch.as_tensor(pose.vec3, dtype=torch.float32) + heading[agent_idx, step_idx] = float(pose.yaw()) + valid_mask[agent_idx, step_idx] = True + + return ( + { + "xyz": xyz, + "heading": heading, + "valid_mask": valid_mask, + "lwh": lwh, + "track_ids": track_ids, + "class_ids": class_ids, + "num_obstacles": num_agents, + }, + agent_object_ids, + agent_is_static, + ) + + +def _track_id_as_int(track_id: str, fallback_idx: int) -> int: + try: + return int(track_id) + except (TypeError, ValueError): + return fallback_idx + + +def _class_id_from_label(label: str) -> int: + normalized = str(label).lower() + class_name_to_id = OBSTACLE_CLASS_NAME_TO_ID + if normalized in class_name_to_id: + return class_name_to_id[normalized] + if normalized in {"automobile", "vehicle", "car"}: + return class_name_to_id["car"] + if normalized in {"person", "pedestrian"} or normalized.startswith("person"): + return class_name_to_id["pedestrian"] + if normalized in {"bicycle", "bike", "cyclist", "cycle"} or normalized.startswith( + "cycle" + ): + return class_name_to_id["cyclist"] + if normalized in {"truck", "bus", "trailer", "other_vehicle"}: + return class_name_to_id["truck"] + return class_name_to_id["others"] + + +def _build_empty_agents_for_steps(steps: int) -> dict: + return { + "xyz": torch.zeros((0, steps, 3), dtype=torch.float32), + "heading": torch.zeros((0, steps), dtype=torch.float32), + "valid_mask": torch.zeros((0, steps), dtype=torch.bool), + "lwh": torch.zeros((0, 3), dtype=torch.float32), + "track_ids": torch.zeros((0,), dtype=torch.long), + "class_ids": torch.zeros((0,), dtype=torch.long), + "num_obstacles": 0, + } + + +_CATK_MAP_ELEMENT_NAME_TO_ENVDATA_MAP_KEY = { + v: k for k, v in ENVDATA_MAP_KEY_TO_CATK_MAP_ELEMENT_NAME.items() +} +_LANE_TYPE_IDS = { + MAP_ELEMENT_NAME2_TYPEID["lane_lines"], + MAP_ELEMENT_NAME2_TYPEID["lane_centers"], + MAP_ELEMENT_NAME2_TYPEID["lane_boundaries"], +} +_ROAD_BOUNDARY_TYPE_ID = MAP_ELEMENT_NAME2_TYPEID["road_boundaries"] + + +def _validate_map_config( + *, + map_element_names: list[str] | None, + map_polyline_filter_mode: str, + map_polyline_number_control_mode: str, +) -> None: + if map_element_names is not None: + invalid_names = [ + name for name in map_element_names if name not in MAP_ELEMENT_NAME2_TYPEID + ] + if invalid_names: + raise ValueError(f"Invalid map_element_names: {invalid_names}") + if map_polyline_filter_mode not in {"v_to_ego", "v_to_ego_and_obs", "disabled"}: + raise ValueError( + f"Invalid map_polyline_filter_mode: {map_polyline_filter_mode!r}" + ) + if map_polyline_number_control_mode not in {"adv", "disabled"}: + raise ValueError( + "Invalid map_polyline_number_control_mode: " + f"{map_polyline_number_control_mode!r}" + ) + + +def preprocess_runtime_map( + map_data: dict, + *, + ego_xyz: torch.Tensor, + agents_xyz: torch.Tensor, + agents_valid_mask: torch.Tensor, + map_element_names: list[str] | None, + map_polyline_filter_mode: str, + map_max_pts_to_ego_distance: float, + map_polyline_number_control_mode: str, + map_adv_max_lane_polylines_num: int, + map_adv_max_road_boundary_num: int, + map_adv_max_other_polylines_num: int, +) -> dict: + _validate_map_config( + map_element_names=map_element_names, + map_polyline_filter_mode=map_polyline_filter_mode, + map_polyline_number_control_mode=map_polyline_number_control_mode, + ) + _apply_map_element_selection(map_data, map_element_names) + + if map_polyline_filter_mode != "disabled": + _apply_map_distance_filter( + map_data, + ego_xyz=ego_xyz, + agents_xyz=agents_xyz, + agents_valid_mask=agents_valid_mask, + mode=map_polyline_filter_mode, + dist_th=map_max_pts_to_ego_distance, + ) + + if map_polyline_number_control_mode == "adv": + _apply_map_adv_count_filter( + map_data, + max_lane_polylines_num=map_adv_max_lane_polylines_num, + max_road_boundary_num=map_adv_max_road_boundary_num, + max_other_polylines_num=map_adv_max_other_polylines_num, + ) + + return map_data + + +def _apply_map_element_selection( + map_data: dict, map_element_names: list[str] | None +) -> None: + if map_element_names is None: + return + selected_env_keys = { + _CATK_MAP_ELEMENT_NAME_TO_ENVDATA_MAP_KEY[name] + for name in map_element_names + if name in _CATK_MAP_ELEMENT_NAME_TO_ENVDATA_MAP_KEY + } + for env_key in ENVDATA_MAP_KEY_TO_CATK_MAP_ELEMENT_NAME: + if env_key not in selected_env_keys: + map_data[env_key] = None + + +def _apply_map_distance_filter( + map_data: dict, + *, + ego_xyz: torch.Tensor, + agents_xyz: torch.Tensor, + agents_valid_mask: torch.Tensor, + mode: str, + dist_th: float, +) -> None: + if dist_th <= 0: + return + ref_xy = ego_xyz[:, :2] + if mode == "v_to_ego_and_obs": + valid_agent_xy = agents_xyz[agents_valid_mask][:, :2] + if valid_agent_xy.numel() > 0: + ref_xy = torch.cat([ref_xy, valid_agent_xy], dim=0) + + for element in map_data.values(): + polylines = _get_polylines(element) + if polylines is None: + continue + keep_mask = _polyline_distance_mask(polylines, ref_xy=ref_xy, dist_th=dist_th) + _apply_layer_mask(element, keep_mask) + + +def _polyline_distance_mask( + polylines: torch.Tensor, *, ref_xy: torch.Tensor, dist_th: float +) -> torch.Tensor: + if polylines.shape[0] == 0: + return torch.zeros((0,), dtype=torch.bool, device=polylines.device) + ref_xy = ref_xy.to(device=polylines.device, dtype=polylines.dtype) + if ref_xy.numel() == 0: + return torch.zeros( + (polylines.shape[0],), dtype=torch.bool, device=polylines.device + ) + diff = polylines[:, :, None, :2] - ref_xy[None, None, :, :] + min_dist_sq = diff.square().sum(dim=-1).amin(dim=(1, 2)) + return min_dist_sq <= dist_th**2 + + +def _apply_map_adv_count_filter( + map_data: dict, + *, + max_lane_polylines_num: int, + max_road_boundary_num: int, + max_other_polylines_num: int, +) -> None: + layer_entries = [] + labels = [] + for element in map_data.values(): + polylines = _get_polylines(element) + if polylines is None: + continue + label = element["label"] + layer_entries.append((element, polylines.shape[0])) + labels.append(label.to(device=polylines.device, dtype=torch.long)) + + if not labels: + return + + all_labels = torch.cat(labels, dim=0) + keep_mask = _adv_count_mask( + all_labels, + max_lane_polylines_num=max_lane_polylines_num, + max_road_boundary_num=max_road_boundary_num, + max_other_polylines_num=max_other_polylines_num, + ) + + offset = 0 + for element, count in layer_entries: + layer_mask = keep_mask[offset : offset + count] + offset += count + _apply_layer_mask(element, layer_mask) + + +def _adv_count_mask( + labels: torch.Tensor, + *, + max_lane_polylines_num: int, + max_road_boundary_num: int, + max_other_polylines_num: int, +) -> torch.Tensor: + lane_mask = torch.zeros_like(labels, dtype=torch.bool) + for type_id in _LANE_TYPE_IDS: + lane_mask = lane_mask | (labels == type_id) + road_boundary_mask = labels == _ROAD_BOUNDARY_TYPE_ID + other_mask = ~lane_mask & ~road_boundary_mask + + keep_mask = torch.zeros_like(labels, dtype=torch.bool) + _select_up_to(keep_mask, lane_mask, max_lane_polylines_num) + _select_up_to(keep_mask, road_boundary_mask, max_road_boundary_num) + _select_up_to(keep_mask, other_mask, max_other_polylines_num) + return keep_mask + + +def _select_up_to( + target_mask: torch.Tensor, candidate_mask: torch.Tensor, max_count: int +) -> None: + candidate_indices = torch.where(candidate_mask)[0] + if candidate_indices.numel() <= max_count: + target_mask[candidate_indices] = True + return + perm = torch.randperm(candidate_indices.shape[0], device=candidate_indices.device) + target_mask[candidate_indices[perm[:max_count]]] = True + + +def _get_polylines(element: dict | None) -> torch.Tensor | None: + if not isinstance(element, dict): + return None + polylines = element.get("polylines") + if not torch.is_tensor(polylines) or polylines.shape[0] == 0: + return None + return polylines + + +def _apply_layer_mask(element: dict, keep_mask: torch.Tensor) -> None: + keep_mask = keep_mask.to(device=element["polylines"].device, dtype=torch.bool) + if keep_mask.sum().item() == 0: + element["polylines"] = element["polylines"][:0] + element["label"] = element["label"][:0] + return + element["polylines"] = element["polylines"][keep_mask] + element["label"] = element["label"][keep_mask] + for optional_key in ("polylines_styles", "polylines_colors"): + value = element.get(optional_key) + if torch.is_tensor(value) and value.shape[0] == keep_mask.shape[0]: + element[optional_key] = value[keep_mask] diff --git a/src/trafficsim/alpasim_trafficsim/catk/smart/__init__.py b/src/trafficsim/alpasim_trafficsim/catk/smart/__init__.py new file mode 100644 index 00000000..b44fc88f --- /dev/null +++ b/src/trafficsim/alpasim_trafficsim/catk/smart/__init__.py @@ -0,0 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation + +from .model.smart import SMART + +__all__ = ["SMART"] diff --git a/src/trafficsim/alpasim_trafficsim/catk/smart/layers/__init__.py b/src/trafficsim/alpasim_trafficsim/catk/smart/layers/__init__.py new file mode 100644 index 00000000..79c76921 --- /dev/null +++ b/src/trafficsim/alpasim_trafficsim/catk/smart/layers/__init__.py @@ -0,0 +1,8 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation + +from .attention_layer import AttentionLayer +from .fourier_embedding import FourierEmbedding, MLPEmbedding +from .mlp_layer import MLPLayer + +__all__ = ["AttentionLayer", "FourierEmbedding", "MLPEmbedding", "MLPLayer"] diff --git a/src/trafficsim/alpasim_trafficsim/catk/smart/layers/attention_layer.py b/src/trafficsim/alpasim_trafficsim/catk/smart/layers/attention_layer.py new file mode 100644 index 00000000..8a3759dc --- /dev/null +++ b/src/trafficsim/alpasim_trafficsim/catk/smart/layers/attention_layer.py @@ -0,0 +1,116 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation + + +from typing import Optional, Tuple, Union + +import torch +import torch.nn as nn +from alpasim_trafficsim.catk.smart.utils import weight_init +from torch_geometric.nn.conv import MessagePassing +from torch_geometric.utils import softmax + + +class AttentionLayer(MessagePassing): + def __init__( + self, + hidden_dim: int, + num_heads: int, + head_dim: int, + dropout: float, + bipartite: bool, + has_pos_emb: bool, + **kwargs, + ) -> None: + super(AttentionLayer, self).__init__(aggr="add", node_dim=0, **kwargs) + self.num_heads = num_heads + self.head_dim = head_dim + self.has_pos_emb = has_pos_emb + self.scale = head_dim**-0.5 + + self.to_q = nn.Linear(hidden_dim, head_dim * num_heads) + self.to_k = nn.Linear(hidden_dim, head_dim * num_heads, bias=False) + self.to_v = nn.Linear(hidden_dim, head_dim * num_heads) + if has_pos_emb: + self.to_k_r = nn.Linear(hidden_dim, head_dim * num_heads, bias=False) + self.to_v_r = nn.Linear(hidden_dim, head_dim * num_heads) + self.to_s = nn.Linear(hidden_dim, head_dim * num_heads) + self.to_g = nn.Linear(head_dim * num_heads + hidden_dim, head_dim * num_heads) + self.to_out = nn.Linear(head_dim * num_heads, hidden_dim) + self.attn_drop = nn.Dropout(dropout) + self.ff_mlp = nn.Sequential( + nn.Linear(hidden_dim, hidden_dim * 4), + nn.ReLU(inplace=True), + nn.Dropout(dropout), + nn.Linear(hidden_dim * 4, hidden_dim), + ) + if bipartite: + self.attn_prenorm_x_src = nn.LayerNorm(hidden_dim) + self.attn_prenorm_x_dst = nn.LayerNorm(hidden_dim) + else: + self.attn_prenorm_x_src = nn.LayerNorm(hidden_dim) + self.attn_prenorm_x_dst = self.attn_prenorm_x_src + if has_pos_emb: + self.attn_prenorm_r = nn.LayerNorm(hidden_dim) + self.attn_postnorm = nn.LayerNorm(hidden_dim) + self.ff_prenorm = nn.LayerNorm(hidden_dim) + self.ff_postnorm = nn.LayerNorm(hidden_dim) + self.apply(weight_init) + + def forward( + self, + x: Union[torch.Tensor, Tuple[torch.Tensor, torch.Tensor]], + r: Optional[torch.Tensor], + edge_index: torch.Tensor, + ) -> torch.Tensor: + if isinstance(x, torch.Tensor): + x_src = x_dst = self.attn_prenorm_x_src(x) + else: + x_src, x_dst = x + x_src = self.attn_prenorm_x_src(x_src) + x_dst = self.attn_prenorm_x_dst(x_dst) + x = x[1] + if self.has_pos_emb and r is not None: + r = self.attn_prenorm_r(r) + x = x + self.attn_postnorm(self._attn_block(x_src, x_dst, r, edge_index)) + x = x + self.ff_postnorm(self._ff_block(self.ff_prenorm(x))) + return x + + def message( + self, + q_i: torch.Tensor, + k_j: torch.Tensor, + v_j: torch.Tensor, + r: Optional[torch.Tensor], + index: torch.Tensor, + ptr: Optional[torch.Tensor], + ) -> torch.Tensor: + if self.has_pos_emb and r is not None: + k_j = k_j + self.to_k_r(r).view(-1, self.num_heads, self.head_dim) + v_j = v_j + self.to_v_r(r).view(-1, self.num_heads, self.head_dim) + sim = (q_i * k_j).sum(dim=-1) * self.scale + attn = softmax(sim, index, ptr) + self.attention_weight = attn.sum(-1).detach() + attn = self.attn_drop(attn) + return v_j * attn.unsqueeze(-1) + + def update(self, inputs: torch.Tensor, x_dst: torch.Tensor) -> torch.Tensor: + inputs = inputs.view(-1, self.num_heads * self.head_dim) + g = torch.sigmoid(self.to_g(torch.cat([inputs, x_dst], dim=-1))) + return inputs + g * (self.to_s(x_dst) - inputs) + + def _attn_block( + self, + x_src: torch.Tensor, + x_dst: torch.Tensor, + r: Optional[torch.Tensor], + edge_index: torch.Tensor, + ) -> torch.Tensor: + q = self.to_q(x_dst).view(-1, self.num_heads, self.head_dim) + k = self.to_k(x_src).view(-1, self.num_heads, self.head_dim) + v = self.to_v(x_src).view(-1, self.num_heads, self.head_dim) + agg = self.propagate(edge_index=edge_index, x_dst=x_dst, q=q, k=k, v=v, r=r) + return self.to_out(agg) + + def _ff_block(self, x: torch.Tensor) -> torch.Tensor: + return self.ff_mlp(x) diff --git a/src/trafficsim/alpasim_trafficsim/catk/smart/layers/fourier_embedding.py b/src/trafficsim/alpasim_trafficsim/catk/smart/layers/fourier_embedding.py new file mode 100644 index 00000000..c3156f1c --- /dev/null +++ b/src/trafficsim/alpasim_trafficsim/catk/smart/layers/fourier_embedding.py @@ -0,0 +1,90 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation + +import math +from typing import List, Optional + +import torch +import torch.nn as nn +from alpasim_trafficsim.catk.smart.utils import weight_init + + +class FourierEmbedding(nn.Module): + def __init__(self, input_dim: int, hidden_dim: int, num_freq_bands: int) -> None: + super(FourierEmbedding, self).__init__() + self.input_dim = input_dim + self.hidden_dim = hidden_dim + + self.freqs = nn.Embedding(input_dim, num_freq_bands) if input_dim != 0 else None + self.mlps = nn.ModuleList( + [ + nn.Sequential( + nn.Linear(num_freq_bands * 2 + 1, hidden_dim), + nn.LayerNorm(hidden_dim), + nn.ReLU(inplace=True), + nn.Linear(hidden_dim, hidden_dim), + ) + for _ in range(input_dim) + ] + ) + self.to_out = nn.Sequential( + nn.LayerNorm(hidden_dim), + nn.ReLU(inplace=True), + nn.Linear(hidden_dim, hidden_dim), + ) + self.apply(weight_init) + + def forward( + self, + continuous_inputs: Optional[torch.Tensor] = None, + categorical_embs: Optional[List[torch.Tensor]] = None, + ) -> torch.Tensor: + if continuous_inputs is None: + if categorical_embs is not None: + x = torch.stack(categorical_embs).sum(dim=0) + else: + raise ValueError("Both continuous_inputs and categorical_embs are None") + else: + x = continuous_inputs.unsqueeze(-1) * self.freqs.weight * 2 * math.pi + # Warning: if your data are noisy, don't use learnable sinusoidal embedding + x = torch.cat([x.cos(), x.sin(), continuous_inputs.unsqueeze(-1)], dim=-1) + continuous_embs: List[Optional[torch.Tensor]] = [None] * self.input_dim + for i in range(self.input_dim): + continuous_embs[i] = self.mlps[i](x[:, i]) + x = torch.stack(continuous_embs).sum(dim=0) + if categorical_embs is not None: + x = x + torch.stack(categorical_embs).sum(dim=0) + return self.to_out(x) + + +class MLPEmbedding(nn.Module): + def __init__(self, input_dim: int, hidden_dim: int) -> None: + super(MLPEmbedding, self).__init__() + self.input_dim = input_dim + self.hidden_dim = hidden_dim + self.mlp = nn.Sequential( + nn.Linear(input_dim, 128), + nn.LayerNorm(128), + nn.ReLU(inplace=True), + nn.Linear(128, hidden_dim), + nn.LayerNorm(hidden_dim), + nn.ReLU(inplace=True), + nn.Linear(hidden_dim, hidden_dim), + ) + self.apply(weight_init) + + def forward( + self, + continuous_inputs: Optional[torch.Tensor] = None, + categorical_embs: Optional[List[torch.Tensor]] = None, + ) -> torch.Tensor: + if continuous_inputs is None: + if categorical_embs is not None: + x = torch.stack(categorical_embs).sum(dim=0) + else: + raise ValueError("Both continuous_inputs and categorical_embs are None") + else: + x = self.mlp(continuous_inputs) + if categorical_embs is not None: + x = x + torch.stack(categorical_embs).sum(dim=0) + return x diff --git a/src/trafficsim/alpasim_trafficsim/catk/smart/layers/mlp_layer.py b/src/trafficsim/alpasim_trafficsim/catk/smart/layers/mlp_layer.py new file mode 100644 index 00000000..c0f8d78f --- /dev/null +++ b/src/trafficsim/alpasim_trafficsim/catk/smart/layers/mlp_layer.py @@ -0,0 +1,21 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation + +import torch +import torch.nn as nn +from alpasim_trafficsim.catk.smart.utils import weight_init + + +class MLPLayer(nn.Module): + def __init__(self, input_dim: int, hidden_dim: int, output_dim: int) -> None: + super(MLPLayer, self).__init__() + self.mlp = nn.Sequential( + nn.Linear(input_dim, hidden_dim), + nn.LayerNorm(hidden_dim), + nn.ReLU(inplace=True), + nn.Linear(hidden_dim, output_dim), + ) + self.apply(weight_init) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + return self.mlp(x) diff --git a/src/trafficsim/alpasim_trafficsim/catk/smart/model/__init__.py b/src/trafficsim/alpasim_trafficsim/catk/smart/model/__init__.py new file mode 100644 index 00000000..75d30d89 --- /dev/null +++ b/src/trafficsim/alpasim_trafficsim/catk/smart/model/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation diff --git a/src/trafficsim/alpasim_trafficsim/catk/smart/model/smart.py b/src/trafficsim/alpasim_trafficsim/catk/smart/model/smart.py new file mode 100644 index 00000000..14dbfc27 --- /dev/null +++ b/src/trafficsim/alpasim_trafficsim/catk/smart/model/smart.py @@ -0,0 +1,19 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation + +from alpasim_trafficsim.catk.smart.modules.smart_decoder import SMARTDecoder +from alpasim_trafficsim.catk.smart.tokens.token_processor import TokenProcessor +from torch import nn + + +class SMART(nn.Module): + def __init__(self, model_config) -> None: + super().__init__() + self.num_historical_steps = model_config.decoder.num_historical_steps + self.token_processor = TokenProcessor(**model_config.token_processor) + + self.encoder = SMARTDecoder( + **model_config.decoder, n_token_agent=self.token_processor.n_token_agent + ) + + self.validation_rollout_sampling = model_config.validation_rollout_sampling diff --git a/src/trafficsim/alpasim_trafficsim/catk/smart/modules/__init__.py b/src/trafficsim/alpasim_trafficsim/catk/smart/modules/__init__.py new file mode 100644 index 00000000..75d30d89 --- /dev/null +++ b/src/trafficsim/alpasim_trafficsim/catk/smart/modules/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation diff --git a/src/trafficsim/alpasim_trafficsim/catk/smart/modules/agent_decoder.py b/src/trafficsim/alpasim_trafficsim/catk/smart/modules/agent_decoder.py new file mode 100644 index 00000000..98a8d25d --- /dev/null +++ b/src/trafficsim/alpasim_trafficsim/catk/smart/modules/agent_decoder.py @@ -0,0 +1,1262 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation + +from typing import Dict, Optional + +import torch +import torch.nn as nn +from alpasim_trafficsim.catk.smart.layers import MLPLayer +from alpasim_trafficsim.catk.smart.layers.attention_layer import AttentionLayer +from alpasim_trafficsim.catk.smart.layers.fourier_embedding import ( + FourierEmbedding, + MLPEmbedding, +) +from alpasim_trafficsim.catk.smart.utils import ( + angle_between_2d_vectors, + sample_next_token_traj, + transform_to_global, + weight_init, + wrap_angle, +) +from omegaconf import DictConfig +from torch_cluster import radius, radius_graph +from torch_geometric.utils import dense_to_sparse, subgraph + + +class SMARTAgentDecoder(nn.Module): + def __init__( + self, + hidden_dim: int, + num_historical_steps: int, + num_future_steps: int, + time_span: Optional[int], + pl2a_radius: float, + a2a_radius: float, + num_freq_bands: int, + num_layers: int, + num_heads: int, + head_dim: int, + dropout: float, + hist_drop_prob: float, + n_token_agent: int, + num_agent_types: int, + ) -> None: + super(SMARTAgentDecoder, self).__init__() + self.hidden_dim = hidden_dim + self.num_historical_steps = num_historical_steps + self.num_future_steps = num_future_steps + self.time_span = time_span if time_span is not None else num_historical_steps + self.pl2a_radius = pl2a_radius + self.a2a_radius = a2a_radius + self.num_layers = num_layers + self.shift = 5 + self.hist_drop_prob = hist_drop_prob + + input_dim_x_a = 2 + input_dim_r_t = 4 + input_dim_r_pt2a = 3 + input_dim_r_a2a = 3 + input_dim_token = 8 + + self.type_a_emb = nn.Embedding(num_agent_types, hidden_dim) + self.shape_emb = MLPLayer(3, hidden_dim, hidden_dim) + + self.x_a_emb = FourierEmbedding( + input_dim=input_dim_x_a, + hidden_dim=hidden_dim, + num_freq_bands=num_freq_bands, + ) + self.r_t_emb = FourierEmbedding( + input_dim=input_dim_r_t, + hidden_dim=hidden_dim, + num_freq_bands=num_freq_bands, + ) + self.r_pt2a_emb = FourierEmbedding( + input_dim=input_dim_r_pt2a, + hidden_dim=hidden_dim, + num_freq_bands=num_freq_bands, + ) + self.r_a2a_emb = FourierEmbedding( + input_dim=input_dim_r_a2a, + hidden_dim=hidden_dim, + num_freq_bands=num_freq_bands, + ) + self.token_emb_veh = MLPEmbedding( + input_dim=input_dim_token, hidden_dim=hidden_dim + ) + self.token_emb_ped = MLPEmbedding( + input_dim=input_dim_token, hidden_dim=hidden_dim + ) + self.token_emb_cyc = MLPEmbedding( + input_dim=input_dim_token, hidden_dim=hidden_dim + ) + self.fusion_emb = MLPEmbedding( + input_dim=self.hidden_dim * 2, hidden_dim=self.hidden_dim + ) + + self.t_attn_layers = nn.ModuleList( + [ + AttentionLayer( + hidden_dim=hidden_dim, + num_heads=num_heads, + head_dim=head_dim, + dropout=dropout, + bipartite=False, + has_pos_emb=True, + ) + for _ in range(num_layers) + ] + ) + self.pt2a_attn_layers = nn.ModuleList( + [ + AttentionLayer( + hidden_dim=hidden_dim, + num_heads=num_heads, + head_dim=head_dim, + dropout=dropout, + bipartite=True, + has_pos_emb=True, + ) + for _ in range(num_layers) + ] + ) + self.a2a_attn_layers = nn.ModuleList( + [ + AttentionLayer( + hidden_dim=hidden_dim, + num_heads=num_heads, + head_dim=head_dim, + dropout=dropout, + bipartite=False, + has_pos_emb=True, + ) + for _ in range(num_layers) + ] + ) + self.token_predict_head = MLPLayer( + input_dim=hidden_dim, hidden_dim=hidden_dim, output_dim=n_token_agent + ) + self.apply(weight_init) + + def agent_token_embedding( + self, + agent_token_index, # [n_agent, n_step] + trajectory_token_veh, # [n_token, 8] + trajectory_token_ped, # [n_token, 8] + trajectory_token_cyc, # [n_token, 8] + pos_a, # [n_agent, n_step, 2] + head_vector_a, # [n_agent, n_step, 2] + agent_type, # [n_agent] + agent_shape, # [n_agent, 3] + inference=False, + ): + n_agent, n_step, traj_dim = pos_a.shape + _device = pos_a.device + + veh_mask = agent_type == 0 + ped_mask = agent_type == 1 + cyc_mask = agent_type == 2 + # [n_token, hidden_dim] + agent_token_emb_veh = self.token_emb_veh(trajectory_token_veh) + agent_token_emb_ped = self.token_emb_ped(trajectory_token_ped) + agent_token_emb_cyc = self.token_emb_cyc(trajectory_token_cyc) + agent_token_emb = torch.zeros( + (n_agent, n_step, self.hidden_dim), device=_device, dtype=pos_a.dtype + ) + agent_token_emb[veh_mask] = agent_token_emb_veh[agent_token_index[veh_mask]] + agent_token_emb[ped_mask] = agent_token_emb_ped[agent_token_index[ped_mask]] + agent_token_emb[cyc_mask] = agent_token_emb_cyc[agent_token_index[cyc_mask]] + + motion_vector_a = torch.cat( + [ + pos_a.new_zeros(agent_token_index.shape[0], 1, traj_dim), + pos_a[:, 1:] - pos_a[:, :-1], + ], + dim=1, + ) # [n_agent, n_step, 2] + feature_a = torch.stack( + [ + torch.norm(motion_vector_a[:, :, :2], p=2, dim=-1), + angle_between_2d_vectors( + ctr_vector=head_vector_a, nbr_vector=motion_vector_a[:, :, :2] + ), + ], + dim=-1, + ) # [n_agent, n_step, 2] + categorical_embs = [ + self.type_a_emb(agent_type.long()), + self.shape_emb(agent_shape), + ] # List of len=2, shape [n_agent, hidden_dim] + + x_a = self.x_a_emb( + continuous_inputs=feature_a.view(-1, feature_a.size(-1)), + categorical_embs=[ + v.repeat_interleave(repeats=n_step, dim=0) for v in categorical_embs + ], + ) # [n_agent*n_step, hidden_dim] + x_a = x_a.view(-1, n_step, self.hidden_dim) # [n_agent, n_step, hidden_dim] + + feat_a = torch.cat((agent_token_emb, x_a), dim=-1) + feat_a = self.fusion_emb(feat_a) + + if inference: + return ( + feat_a, # [n_agent, n_step, hidden_dim] + agent_token_emb, # [n_agent, n_step, hidden_dim] + agent_token_emb_veh, # [n_agent, hidden_dim] + agent_token_emb_ped, # [n_agent, hidden_dim] + agent_token_emb_cyc, # [n_agent, hidden_dim] + veh_mask, # [n_agent] + ped_mask, # [n_agent] + cyc_mask, # [n_agent] + categorical_embs, # List of len=2, shape [n_agent, hidden_dim] + ) + else: + return feat_a # [n_agent, n_step, hidden_dim] + + def build_temporal_edge( + self, + pos_a, # [n_agent, n_step, 2] + head_a, # [n_agent, n_step] + head_vector_a, # [n_agent, n_step, 2], + mask, # [n_agent, n_step] + inference_mask=None, # [n_agent, n_step] + ): + pos_t = pos_a.flatten(0, 1) + head_t = head_a.flatten(0, 1) + head_vector_t = head_vector_a.flatten(0, 1) + + if self.hist_drop_prob > 0 and self.training: + _mask_keep = torch.bernoulli( + torch.ones_like(mask) * (1 - self.hist_drop_prob) + ).bool() + mask = mask & _mask_keep + + if inference_mask is not None: + mask_t = mask.unsqueeze(2) & inference_mask.unsqueeze(1) + else: + mask_t = mask.unsqueeze(2) & mask.unsqueeze(1) + + edge_index_t = dense_to_sparse(mask_t)[0] + edge_index_t = edge_index_t[:, edge_index_t[1] > edge_index_t[0]] + edge_index_t = edge_index_t[ + :, edge_index_t[1] - edge_index_t[0] <= self.time_span / self.shift + ] + rel_pos_t = pos_t[edge_index_t[0]] - pos_t[edge_index_t[1]] + rel_pos_t = rel_pos_t[:, :2] + rel_head_t = wrap_angle(head_t[edge_index_t[0]] - head_t[edge_index_t[1]]) + r_t = torch.stack( + [ + torch.norm(rel_pos_t, p=2, dim=-1), + angle_between_2d_vectors( + ctr_vector=head_vector_t[edge_index_t[1]], nbr_vector=rel_pos_t + ), + rel_head_t, + edge_index_t[0] - edge_index_t[1], + ], + dim=-1, + ) + r_t = self.r_t_emb(continuous_inputs=r_t, categorical_embs=None) + return edge_index_t, r_t + + def build_interaction_edge( + self, + pos_a, # [n_agent, n_step, 2] + head_a, # [n_agent, n_step] + head_vector_a, # [n_agent, n_step, 2] + batch_s, # [n_agent*n_step] + mask, # [n_agent, n_step] + ): + mask = mask.transpose(0, 1).reshape(-1) + pos_s = pos_a.transpose(0, 1).flatten(0, 1) + head_s = head_a.transpose(0, 1).reshape(-1) + head_vector_s = head_vector_a.transpose(0, 1).reshape(-1, 2) + edge_index_a2a = radius_graph( + x=pos_s[:, :2], + r=self.a2a_radius, + batch=batch_s, + loop=False, + max_num_neighbors=300, + ) + edge_index_a2a = subgraph(subset=mask, edge_index=edge_index_a2a)[0] + rel_pos_a2a = pos_s[edge_index_a2a[0]] - pos_s[edge_index_a2a[1]] + rel_head_a2a = wrap_angle(head_s[edge_index_a2a[0]] - head_s[edge_index_a2a[1]]) + r_a2a = torch.stack( + [ + torch.norm(rel_pos_a2a[:, :2], p=2, dim=-1), + angle_between_2d_vectors( + ctr_vector=head_vector_s[edge_index_a2a[1]], + nbr_vector=rel_pos_a2a[:, :2], + ), + rel_head_a2a, + ], + dim=-1, + ) + r_a2a = self.r_a2a_emb(continuous_inputs=r_a2a, categorical_embs=None) + return edge_index_a2a, r_a2a + + def build_map2agent_edge( + self, + pos_pl, # [n_pl, 2] + orient_pl, # [n_pl] + pos_a, # [n_agent, n_step, 2] + head_a, # [n_agent, n_step] + head_vector_a, # [n_agent, n_step, 2] + mask, # [n_agent, n_step] + batch_s, # [n_agent*n_step] + batch_pl, # [n_pl*n_step] + ): + n_step = pos_a.shape[1] + mask_pl2a = mask.transpose(0, 1).reshape(-1) + pos_s = pos_a.transpose(0, 1).flatten(0, 1) + head_s = head_a.transpose(0, 1).reshape(-1) + head_vector_s = head_vector_a.transpose(0, 1).reshape(-1, 2) + pos_pl = pos_pl.repeat(n_step, 1) + orient_pl = orient_pl.repeat(n_step) + edge_index_pl2a = radius( + x=pos_s[:, :2], + y=pos_pl[:, :2], + r=self.pl2a_radius, + batch_x=batch_s, + batch_y=batch_pl, + max_num_neighbors=300, + ) + edge_index_pl2a = edge_index_pl2a[:, mask_pl2a[edge_index_pl2a[1]]] + rel_pos_pl2a = pos_pl[edge_index_pl2a[0]] - pos_s[edge_index_pl2a[1]] + rel_orient_pl2a = wrap_angle( + orient_pl[edge_index_pl2a[0]] - head_s[edge_index_pl2a[1]] + ) + r_pl2a = torch.stack( + [ + torch.norm(rel_pos_pl2a[:, :2], p=2, dim=-1), + angle_between_2d_vectors( + ctr_vector=head_vector_s[edge_index_pl2a[1]], + nbr_vector=rel_pos_pl2a[:, :2], + ), + rel_orient_pl2a, + ], + dim=-1, + ) + r_pl2a = self.r_pt2a_emb(continuous_inputs=r_pl2a, categorical_embs=None) + return edge_index_pl2a, r_pl2a + + def forward( + self, + tokenized_agent: Dict[str, torch.Tensor], + map_feature: Dict[str, torch.Tensor], + ) -> Dict[str, torch.Tensor]: + mask = tokenized_agent["valid_mask"] + pos_a = tokenized_agent["sampled_pos"] + head_a = tokenized_agent["sampled_heading"] + head_vector_a = torch.stack([head_a.cos(), head_a.sin()], dim=-1) + n_agent, n_step = head_a.shape + + # ! get agent token embeddings + feat_a = self.agent_token_embedding( + agent_token_index=tokenized_agent["sampled_idx"], # [n_ag, n_step] + trajectory_token_veh=tokenized_agent["trajectory_token_veh"], + trajectory_token_ped=tokenized_agent["trajectory_token_ped"], + trajectory_token_cyc=tokenized_agent["trajectory_token_cyc"], + pos_a=pos_a, # [n_agent, n_step, 2] + head_vector_a=head_vector_a, # [n_agent, n_step, 2] + agent_type=tokenized_agent["type"], # [n_agent] + agent_shape=tokenized_agent["shape"], # [n_agent, 3] + ) # feat_a: [n_agent, n_step, hidden_dim] + + # ! build temporal, interaction and map2agent edges + edge_index_t, r_t = self.build_temporal_edge( + pos_a=pos_a, # [n_agent, n_step, 2] + head_a=head_a, # [n_agent, n_step] + head_vector_a=head_vector_a, # [n_agent, n_step, 2] + mask=mask, # [n_agent, n_step] + ) # edge_index_t: [2, n_edge_t], r_t: [n_edge_t, hidden_dim] + + batch_s = torch.cat( + [ + tokenized_agent["batch"] + tokenized_agent["num_graphs"] * t + for t in range(n_step) + ], + dim=0, + ) # [n_agent*n_step] + if map_feature is not None: + batch_pl = torch.cat( + [ + map_feature["batch"] + tokenized_agent["num_graphs"] * t + for t in range(n_step) + ], + dim=0, + ) # [n_pl*n_step] + + edge_index_a2a, r_a2a = self.build_interaction_edge( + pos_a=pos_a, # [n_agent, n_step, 2] + head_a=head_a, # [n_agent, n_step] + head_vector_a=head_vector_a, # [n_agent, n_step, 2] + batch_s=batch_s, # [n_agent*n_step] + mask=mask, # [n_agent, n_step] + ) # edge_index_a2a: [2, n_edge_a2a], r_a2a: [n_edge_a2a, hidden_dim] + + if map_feature is not None: + edge_index_pl2a, r_pl2a = self.build_map2agent_edge( + pos_pl=map_feature["position"], # [n_pl, 2] + orient_pl=map_feature["orientation"], # [n_pl] + pos_a=pos_a, # [n_agent, n_step, 2] + head_a=head_a, # [n_agent, n_step] + head_vector_a=head_vector_a, # [n_agent, n_step, 2] + mask=mask, # [n_agent, n_step] + batch_s=batch_s, # [n_agent*n_step] + batch_pl=batch_pl, # [n_pl*n_step] + ) + + # ! attention layers + # [n_step*n_pl, hidden_dim] + if map_feature is not None: + feat_map = ( + map_feature["pt_token"] + .unsqueeze(0) + .expand(n_step, -1, -1) + .flatten(0, 1) + ) + + for i in range(self.num_layers): + feat_a = feat_a.flatten(0, 1) # [n_agent*n_step, hidden_dim] + feat_a = self.t_attn_layers[i](feat_a, r_t, edge_index_t) + # [n_step*n_agent, hidden_dim] + feat_a = feat_a.view(n_agent, n_step, -1).transpose(0, 1).flatten(0, 1) + + if map_feature is not None: + feat_a = self.pt2a_attn_layers[i]( + (feat_map, feat_a), r_pl2a, edge_index_pl2a + ) + + feat_a = self.a2a_attn_layers[i](feat_a, r_a2a, edge_index_a2a) + feat_a = feat_a.view(n_step, n_agent, -1).transpose(0, 1) + + # ! final mlp to get outputs + next_token_logits = self.token_predict_head(feat_a) + + return { + # action that goes from [(10->15), ..., (85->90)] + "next_token_logits": next_token_logits[:, 1:-1], # [n_agent, 16, n_token] + "next_token_valid": tokenized_agent["valid_mask"][:, 1:-1], # [n_agent, 16] + # for step {5, 10, ..., 90} and act [(0->5), (5->10), ..., (85->90)] + "pred_pos": tokenized_agent["sampled_pos"], # [n_agent, 18, 2] + "pred_head": tokenized_agent["sampled_heading"], # [n_agent, 18] + "pred_valid": tokenized_agent["valid_mask"], # [n_agent, 18] + # for step {5, 10, ..., 90} + "gt_pos_raw": tokenized_agent["gt_pos_raw"], # [n_agent, 18, 2] + "gt_head_raw": tokenized_agent["gt_head_raw"], # [n_agent, 18] + "gt_valid_raw": tokenized_agent["gt_valid_raw"], # [n_agent, 18] + # or use the tokenized gt + "gt_pos": tokenized_agent["gt_pos"], # [n_agent, 18, 2] + "gt_head": tokenized_agent["gt_heading"], # [n_agent, 18] + "gt_valid": tokenized_agent["valid_mask"], # [n_agent, 18] + } + + def inference( + self, + tokenized_agent: Dict[str, torch.Tensor], + map_feature: Dict[str, torch.Tensor] | None, + sampling_scheme: DictConfig, + fixed_agent_mask: torch.Tensor | None = None, + ego_fixed: bool = False, + ) -> Dict[str, torch.Tensor]: + """ + Agent trajectory inference with optional ego trajectory conditioning. + + Args: + tokenized_agent: Tokenized agent data + map_feature: Encoded map features + sampling_scheme: Token sampling configuration + fixed_agent_mask: If True, use agent trajectory from tokenized_agent['gt_pos_raw'] + and ['gt_head_raw'] instead of predicting agent motion. This enables + inference where certain agent trajectories are externally provided. + ego_fixed: If True, use ego trajectory from tokenized_agent['gt_pos_raw'] + and ['gt_head_raw'] instead of predicting ego motion. + + Returns: + Dict containing predicted trajectories and other outputs + """ + + n_agent = tokenized_agent["valid_mask"].shape[0] + n_step_future_10hz = self.num_future_steps # 80 or 45 + n_step_future_2hz = n_step_future_10hz // self.shift # 16 or 9 + + step_current_10hz = self.num_historical_steps - 1 # 10 or 15 + step_current_2hz = step_current_10hz // self.shift # 2 or 3 + + # Validate fixed agent trajectory data is available if needed + if fixed_agent_mask is not None or ego_fixed: + required_keys = ["gt_pos_raw", "gt_head_raw", "gt_valid_raw"] + for key in required_keys: + if key not in tokenized_agent: + raise ValueError( + f"fixed_agent_mask not None but required key '{key}' not found in tokenized_agent!" + ) + + pos_a = tokenized_agent["gt_pos"][:, :step_current_2hz].clone() + head_a = tokenized_agent["gt_heading"][:, :step_current_2hz].clone() + head_vector_a = torch.stack([head_a.cos(), head_a.sin()], dim=-1) + ( + feat_a, # [n_agent, step_current_2hz, hidden_dim] + agent_token_emb, # [n_agent, step_current_2hz, hidden_dim] + agent_token_emb_veh, # [n_agent, hidden_dim] + agent_token_emb_ped, # [n_agent, hidden_dim] + agent_token_emb_cyc, # [n_agent, hidden_dim] + veh_mask, # [n_agent] + ped_mask, # [n_agent] + cyc_mask, # [n_agent] + categorical_embs, # List of len=2, shape [n_agent, hidden_dim] + ) = self.agent_token_embedding( + agent_token_index=tokenized_agent["gt_idx"][:, :step_current_2hz], + trajectory_token_veh=tokenized_agent["trajectory_token_veh"], + trajectory_token_ped=tokenized_agent["trajectory_token_ped"], + trajectory_token_cyc=tokenized_agent["trajectory_token_cyc"], + pos_a=pos_a, + head_vector_a=head_vector_a, + agent_type=tokenized_agent["type"], + agent_shape=tokenized_agent["shape"], + inference=True, + ) + + if not self.training: + pred_traj_10hz = torch.zeros( + [n_agent, n_step_future_10hz, 2], dtype=pos_a.dtype, device=pos_a.device + ) + pred_head_10hz = torch.zeros( + [n_agent, n_step_future_10hz], dtype=pos_a.dtype, device=pos_a.device + ) + pred_valid_10hz = torch.zeros( + [n_agent, n_step_future_10hz], dtype=torch.bool, device=pos_a.device + ) + + # SMART expects this to also hold the valid mask for future steps. + pred_valid = tokenized_agent["valid_mask"].clone() + if pred_valid.shape[1] < n_step_future_2hz + step_current_2hz: + pred_valid = torch.cat( + [ + pred_valid, + torch.repeat_interleave( + pred_valid[:, -1:], n_step_future_2hz, dim=1 + ), + ], + dim=1, + ) + + pred_idx_list = [] + next_token_logits_list = [] + sample_logits_list = [] + feat_a_t_dict = {} + for t in range(n_step_future_2hz): # 0 -> 15 + t_now = step_current_2hz - 1 + t # 1 -> 16 + n_step = t_now + 1 # 2 -> 17 + + if t == 0: # init + hist_step = step_current_2hz + batch_s = torch.cat( + [ + tokenized_agent["batch"] + tokenized_agent["num_graphs"] * t + for t in range(hist_step) + ], + dim=0, + ) + if map_feature is not None: + batch_pl = torch.cat( + [ + map_feature["batch"] + tokenized_agent["num_graphs"] * t + for t in range(hist_step) + ], + dim=0, + ) + inference_mask = pred_valid[:, :n_step] + edge_index_t, r_t = self.build_temporal_edge( + pos_a=pos_a, + head_a=head_a, + head_vector_a=head_vector_a, + mask=pred_valid[:, :n_step], + ) + else: + hist_step = 1 + batch_s = tokenized_agent["batch"] + if map_feature is not None: + batch_pl = map_feature["batch"] + inference_mask = pred_valid[:, :n_step].clone() + inference_mask[:, :-1] = False + edge_index_t, r_t = self.build_temporal_edge( + pos_a=pos_a, + head_a=head_a, + head_vector_a=head_vector_a, + mask=pred_valid[:, :n_step], + inference_mask=inference_mask, + ) + edge_index_t[1] = (edge_index_t[1] + 1) // n_step - 1 + + # In the inference stage, we only infer the current stage for recurrent + if map_feature is not None: + edge_index_pl2a, r_pl2a = self.build_map2agent_edge( + pos_pl=map_feature["position"], # [n_pl, 2] + orient_pl=map_feature["orientation"], # [n_pl] + pos_a=pos_a[:, -hist_step:], # [n_agent, hist_step, 2] + head_a=head_a[:, -hist_step:], # [n_agent, hist_step] + head_vector_a=head_vector_a[ + :, -hist_step: + ], # [n_agent, hist_step, 2] + mask=inference_mask[:, -hist_step:], # [n_agent, hist_step] + batch_s=batch_s, # [n_agent*hist_step] + batch_pl=batch_pl, # [n_pl*hist_step] + ) + edge_index_a2a, r_a2a = self.build_interaction_edge( + pos_a=pos_a[:, -hist_step:], # [n_agent, hist_step, 2] + head_a=head_a[:, -hist_step:], # [n_agent, hist_step] + head_vector_a=head_vector_a[:, -hist_step:], # [n_agent, hist_step, 2] + batch_s=batch_s, # [n_agent*hist_step] + mask=inference_mask[:, -hist_step:], # [n_agent, hist_step] + ) + + # ! attention layers + for i in range(self.num_layers): + # [n_agent, n_step, hidden_dim] + _feat_temporal = feat_a if i == 0 else feat_a_t_dict[i] + + if t == 0: # init, process hist_step together + _feat_temporal = self.t_attn_layers[i]( + _feat_temporal.flatten(0, 1), r_t, edge_index_t + ).view(n_agent, n_step, -1) + _feat_temporal = _feat_temporal.transpose(0, 1).flatten(0, 1) + + if map_feature is not None: + # [hist_step*n_pl, hidden_dim] + _feat_map = ( + map_feature["pt_token"] + .unsqueeze(0) + .expand(hist_step, -1, -1) + .flatten(0, 1) + ) + _feat_temporal = self.pt2a_attn_layers[i]( + (_feat_map, _feat_temporal), r_pl2a, edge_index_pl2a + ) + + _feat_temporal = self.a2a_attn_layers[i]( + _feat_temporal, r_a2a, edge_index_a2a + ) + _feat_temporal = _feat_temporal.view(n_step, n_agent, -1).transpose( + 0, 1 + ) + feat_a_now = _feat_temporal[:, -1] # [n_agent, hidden_dim] + + if i + 1 < self.num_layers: + feat_a_t_dict[i + 1] = _feat_temporal + + else: # process one step + feat_a_now = self.t_attn_layers[i]( + (_feat_temporal.flatten(0, 1), _feat_temporal[:, -1]), + r_t, + edge_index_t, + ) + + if map_feature is not None: + feat_a_now = self.pt2a_attn_layers[i]( + (map_feature["pt_token"], feat_a_now), + r_pl2a, + edge_index_pl2a, + ) + feat_a_now = self.a2a_attn_layers[i]( + feat_a_now, r_a2a, edge_index_a2a + ) + + # [n_agent, n_step, hidden_dim] + if i + 1 < self.num_layers: + feat_a_t_dict[i + 1] = torch.cat( + (feat_a_t_dict[i + 1], feat_a_now.unsqueeze(1)), dim=1 + ) + + # ! get outputs + next_token_logits = self.token_predict_head(feat_a_now) + next_token_logits_list.append(next_token_logits) # [n_agent, n_token] + + sampling_args = dict( + token_traj=tokenized_agent["token_traj"], + token_traj_all=tokenized_agent["token_traj_all"], + sampling_scheme=sampling_scheme, + # ! for most-likely sampling + next_token_logits=next_token_logits, + # ! for nearest-pos sampling + pos_now=pos_a[:, t_now], # [n_agent, 2] + head_now=head_a[:, t_now], # [n_agent] + ) + if sampling_scheme.criterium != "topk_prob": + sampling_args.update( + dict( + pos_next_gt=tokenized_agent["gt_pos_raw"][ + :, n_step + ], # [n_agent, 2] + head_next_gt=tokenized_agent["gt_head_raw"][ + :, n_step + ], # [n_agent] + valid_next_gt=tokenized_agent["gt_valid_raw"][ + :, n_step + ], # [n_agent] + token_agent_shape=tokenized_agent[ + "token_agent_shape" + ], # [n_token, 2] + ) + ) + + # next_token_idx: [n_agent], next_token_traj_all: [n_agent, 6, 4, 2] + next_token_idx, next_token_traj_all, sample_logits = sample_next_token_traj( + **sampling_args + ) + + sample_logits_list.append(sample_logits) + pred_idx_list.append(next_token_idx) + + token_traj_global = transform_to_global( + pos_local=next_token_traj_all.flatten(1, 2), # [n_agent, 6*4, 2] + head_local=None, + pos_now=pos_a[:, t_now], # [n_agent, 2] + head_now=head_a[:, t_now], # [n_agent] + )[0].view(*next_token_traj_all.shape) + + if not self.training: + pred_traj_10hz[:, t * 5 : (t + 1) * 5] = token_traj_global[:, 1:].mean( + 2 + ) + diff_xy = token_traj_global[:, 1:, 0] - token_traj_global[:, 1:, 3] + pred_head_10hz[:, t * 5 : (t + 1) * 5] = torch.arctan2( + diff_xy[:, :, 1], diff_xy[:, :, 0] + ) + pred_valid_10hz[:, t * 5 : (t + 1) * 5] = pred_valid[ + :, t_now + ].unsqueeze(-1) + + # ! get pos_a_next and head_a_next, spawn unseen agents + pos_a_next = token_traj_global[:, -1].mean(dim=1) + diff_xy_next = token_traj_global[:, -1, 0] - token_traj_global[:, -1, 3] + head_a_next = torch.arctan2(diff_xy_next[:, 1], diff_xy_next[:, 0]) + + # ! update tensors for for next step + pred_valid[:, n_step] = pred_valid[:, t_now] + + # ! Override positions/headings with provided trajectory if fixed agent mask is provided + if ego_fixed: + # n_step = token index in 2hz mode = 5 steps in 10hz mode + ego_mask = tokenized_agent["ego_mask"] + + # Get ego position, heading, and validity from provided trajectory + ego_pos_next = tokenized_agent["gt_pos_raw"][ + ego_mask, n_step + ] # [n_ego, 2] + ego_head_next = tokenized_agent["gt_head_raw"][ + ego_mask, n_step + ] # [n_ego] + ego_valid_next = tokenized_agent["gt_valid_raw"][ + ego_mask, n_step + ] # [n_ego] + + # Override ego predictions with provided trajectory (for next step context) + pos_a_next[ego_mask] = ego_pos_next + head_a_next[ego_mask] = ego_head_next + pred_valid[ego_mask, n_step] = ego_valid_next # Use provided validity + + # ! CRITICAL FIX: Override token selection for ego to match provided trajectory + # The token_processor already computed the correct gt_idx for ego + if "gt_idx" in tokenized_agent: + # gt_idx shape: [n_agent, n_future_steps] + # Use the pre-computed token index from token_processor + next_token_idx[ego_mask] = tokenized_agent["gt_idx"][ + ego_mask, n_step + ] + + # ! Override positions/headings with provided trajectory if fixed agent mask is provided + if fixed_agent_mask is not None: + # Get ego position, heading, and validity from provided trajectory + agent_pos_next = tokenized_agent["gt_pos_raw"][ + fixed_agent_mask, n_step + ] # [n_agent, 2] + agent_head_next = tokenized_agent["gt_head_raw"][ + fixed_agent_mask, n_step + ] # [n_agent] + agent_valid_next = tokenized_agent["gt_valid_raw"][ + fixed_agent_mask, n_step + ] # [n_agent] + + # Override ego predictions with provided trajectory (for next step context) + pos_a_next[fixed_agent_mask] = agent_pos_next + head_a_next[fixed_agent_mask] = agent_head_next + pred_valid[fixed_agent_mask, n_step] = ( + agent_valid_next # Use provided validity + ) + + # ! CRITICAL FIX: Override token selection for fixed agents to match provided trajectory + # The token_processor already computed the correct gt_idx for fixed agents + if "gt_idx" in tokenized_agent: + # gt_idx shape: [n_agent, n_future_steps] + # Use the pre-computed token index from token_processor + next_token_idx[fixed_agent_mask] = tokenized_agent["gt_idx"][ + fixed_agent_mask, n_step + ] + + pos_a = torch.cat([pos_a, pos_a_next.unsqueeze(1)], dim=1) + head_a = torch.cat([head_a, head_a_next.unsqueeze(1)], dim=1) + head_vector_a_next = torch.stack( + [head_a_next.cos(), head_a_next.sin()], dim=-1 + ) + head_vector_a = torch.cat( + [head_vector_a, head_vector_a_next.unsqueeze(1)], dim=1 + ) + + # ! get agent_token_emb_next + agent_token_emb_next = torch.zeros_like(agent_token_emb[:, 0]) + agent_token_emb_next[veh_mask] = agent_token_emb_veh[ + next_token_idx[veh_mask] + ] + agent_token_emb_next[ped_mask] = agent_token_emb_ped[ + next_token_idx[ped_mask] + ] + agent_token_emb_next[cyc_mask] = agent_token_emb_cyc[ + next_token_idx[cyc_mask] + ] + agent_token_emb = torch.cat( + [agent_token_emb, agent_token_emb_next.unsqueeze(1)], dim=1 + ) + + # ! get feat_a_next + motion_vector_a = pos_a[:, -1] - pos_a[:, -2] # [n_agent, 2] + x_a = torch.stack( + [ + torch.norm(motion_vector_a, p=2, dim=-1), + angle_between_2d_vectors( + ctr_vector=head_vector_a[:, -1], nbr_vector=motion_vector_a + ), + ], + dim=-1, + ) + # [n_agent, hidden_dim] + x_a = self.x_a_emb(continuous_inputs=x_a, categorical_embs=categorical_embs) + # [n_agent, 1, 2*hidden_dim] + feat_a_next = torch.cat((agent_token_emb_next, x_a), dim=-1).unsqueeze(1) + feat_a_next = self.fusion_emb(feat_a_next) + feat_a = torch.cat([feat_a, feat_a_next], dim=1) + + out_dict = { + # action that goes from [(10->15), ..., (85->90)] + "next_token_logits": torch.stack( + next_token_logits_list, dim=1 + ), # [n_agent, n_step_future_2hz] + "next_token_valid": pred_valid[:, step_current_2hz:], # [n_agent, ] + # for step {5, 10, ..., 90} and act [(0->5), (5->10), ..., (85->90)] + "pred_pos": pos_a, # [n_agent, 18, 2] + "pred_head": head_a, # [n_agent, 18] + "pred_valid": pred_valid, # [n_agent, 18] + # for step {5, 10, ..., 90} + "gt_pos_raw": tokenized_agent["gt_pos_raw"], # [n_agent, 18, 2] + "gt_head_raw": tokenized_agent["gt_head_raw"], # [n_agent, 18] + "gt_valid_raw": tokenized_agent["gt_valid_raw"], # [n_agent, 18] + # or use the tokenized gt + "gt_pos": tokenized_agent["gt_pos"], # [n_agent, 18, 2] + "gt_head": tokenized_agent["gt_heading"], # [n_agent, 18] + "gt_valid": tokenized_agent["valid_mask"], # [n_agent, 18] + # [n_agent, 16, n_token] + "sample_logits": torch.stack(sample_logits_list, dim=1), + "pred_idx": torch.stack(pred_idx_list, dim=1), # [n_agent, 16] + } + + if not self.training: # 10hz predictions for runtime inference + out_dict["pred_traj_10hz"] = pred_traj_10hz + out_dict["pred_head_10hz"] = pred_head_10hz + pred_z = tokenized_agent["gt_z_raw"].unsqueeze(1) # [n_agent, 1] + out_dict["pred_z_10hz"] = pred_z.expand(-1, pred_traj_10hz.shape[1]) + out_dict["pred_valid_10hz"] = pred_valid_10hz + + return out_dict + + def inference_with_mask( + self, + tokenized_agent: Dict[str, torch.Tensor], + map_feature: Dict[str, torch.Tensor] | None, + sampling_scheme: DictConfig, + freeze_agent_future: bool = False, + freeze_agent_mask: torch.Tensor | None = None, + freeze_tokenized_agent: Dict[str, torch.Tensor] | None = None, + ) -> Dict[str, torch.Tensor]: + """ + Agent trajectory inference with optional ego trajectory conditioning. + + Args: + tokenized_agent: Tokenized agent data + map_feature: Encoded map features + sampling_scheme: Token sampling configuration + freeze_agents: If True, This enables inference where certain agent trajectories are externally provided. + freeze_agents_mask: Use agent trajectory from + future_tokenized_agent['gt_pos_raw'], + future_tokenized_agent['gt_head_raw'], + future_tokenized_agent['gt_valid_raw'], + and future_tokenized_agent["gt_idx"], + instead of predicting agent motion. + future_tokenized_agent: + + Returns: + Dict containing predicted trajectories and other outputs + """ + n_agent = tokenized_agent["valid_mask"].shape[0] + + n_step_future_10hz = self.num_future_steps # 80 or 45 + n_step_future_2hz = ( + n_step_future_10hz // self.shift + ) # 16 or 9, or 1 if future=5 + + # index + step_current_10hz = self.num_historical_steps - 1 # 10 or 15 + step_current_2hz = step_current_10hz // self.shift # 2 or 3 + + # Validate fixed agent trajectory data is available if needed + if freeze_agent_future and freeze_agent_mask is not None: + # old usage: use tokenized_agent (this includes history, current, and future) + # new usage: use future_tokenized_agent (this includes future only) + required_keys = ["gt_pos_raw", "gt_head_raw", "gt_valid_raw", "gt_idx"] + for key in required_keys: + if key not in freeze_tokenized_agent: + raise ValueError( + f"freeze_agent_mask not None but required key '{key}' not found in tokenized_agent!" + ) + + pos_a = tokenized_agent["gt_pos"][:, :step_current_2hz].clone() + head_a = tokenized_agent["gt_heading"][:, :step_current_2hz].clone() + token_idx = tokenized_agent["gt_idx"][:, :step_current_2hz] + + head_vector_a = torch.stack([head_a.cos(), head_a.sin()], dim=-1) + + ( + feat_a, # [n_agent, step_current_2hz, hidden_dim] + agent_token_emb, # [n_agent, step_current_2hz, hidden_dim] + agent_token_emb_veh, # [n_agent, hidden_dim] + agent_token_emb_ped, # [n_agent, hidden_dim] + agent_token_emb_cyc, # [n_agent, hidden_dim] + veh_mask, # [n_agent] + ped_mask, # [n_agent] + cyc_mask, # [n_agent] + categorical_embs, # List of len=2, shape [n_agent, hidden_dim] + ) = self.agent_token_embedding( + agent_token_index=token_idx, + trajectory_token_veh=tokenized_agent["trajectory_token_veh"], + trajectory_token_ped=tokenized_agent["trajectory_token_ped"], + trajectory_token_cyc=tokenized_agent["trajectory_token_cyc"], + pos_a=pos_a, + head_vector_a=head_vector_a, + agent_type=tokenized_agent["type"], + agent_shape=tokenized_agent["shape"], + inference=True, + ) + + if not self.training: + pred_traj_10hz = torch.zeros( + [n_agent, n_step_future_10hz, 2], dtype=pos_a.dtype, device=pos_a.device + ) + pred_head_10hz = torch.zeros( + [n_agent, n_step_future_10hz], dtype=pos_a.dtype, device=pos_a.device + ) + pred_valid_10hz = torch.zeros( + [n_agent, n_step_future_10hz], dtype=torch.bool, device=pos_a.device + ) + + # SMART expects this to also hold the valid mask for future steps. + pred_valid = tokenized_agent["valid_mask"].clone() + if pred_valid.shape[1] < n_step_future_2hz + step_current_2hz: + pred_valid = torch.cat( + [ + pred_valid, + torch.repeat_interleave( + pred_valid[:, -1:], n_step_future_2hz, dim=1 + ), + ], + dim=1, + ) + + pred_idx_list = [] + next_token_logits_list = [] + sample_logits_list = [] + feat_a_t_dict = {} + for t in range(n_step_future_2hz): # 0 -> 15 + t_now = step_current_2hz - 1 + t # 1 -> 16 (token index) + n_step = t_now + 1 # 2 -> 17 (next token index) + + if t == 0: # init + hist_step = step_current_2hz + batch_s = torch.cat( + [ + tokenized_agent["batch"] + tokenized_agent["num_graphs"] * t + for t in range(hist_step) + ], + dim=0, + ) + if map_feature is not None: + batch_pl = torch.cat( + [ + map_feature["batch"] + tokenized_agent["num_graphs"] * t + for t in range(hist_step) + ], + dim=0, + ) + inference_mask = pred_valid[:, :n_step] + edge_index_t, r_t = self.build_temporal_edge( + pos_a=pos_a, + head_a=head_a, + head_vector_a=head_vector_a, + mask=pred_valid[:, :n_step], + ) + else: + hist_step = 1 + batch_s = tokenized_agent["batch"] + if map_feature is not None: + batch_pl = map_feature["batch"] + inference_mask = pred_valid[:, :n_step].clone() + inference_mask[:, :-1] = False + edge_index_t, r_t = self.build_temporal_edge( + pos_a=pos_a, + head_a=head_a, + head_vector_a=head_vector_a, + mask=pred_valid[:, :n_step], + inference_mask=inference_mask, + ) + edge_index_t[1] = (edge_index_t[1] + 1) // n_step - 1 + + # In the inference stage, we only infer the current stage for recurrent + if map_feature is not None: + edge_index_pl2a, r_pl2a = self.build_map2agent_edge( + pos_pl=map_feature["position"], # [n_pl, 2] + orient_pl=map_feature["orientation"], # [n_pl] + pos_a=pos_a[:, -hist_step:], # [n_agent, hist_step, 2] + head_a=head_a[:, -hist_step:], # [n_agent, hist_step] + head_vector_a=head_vector_a[ + :, -hist_step: + ], # [n_agent, hist_step, 2] + mask=inference_mask[:, -hist_step:], # [n_agent, hist_step] + batch_s=batch_s, # [n_agent*hist_step] + batch_pl=batch_pl, # [n_pl*hist_step] + ) + edge_index_a2a, r_a2a = self.build_interaction_edge( + pos_a=pos_a[:, -hist_step:], # [n_agent, hist_step, 2] + head_a=head_a[:, -hist_step:], # [n_agent, hist_step] + head_vector_a=head_vector_a[:, -hist_step:], # [n_agent, hist_step, 2] + batch_s=batch_s, # [n_agent*hist_step] + mask=inference_mask[:, -hist_step:], # [n_agent, hist_step] + ) + + # ! attention layers + for i in range(self.num_layers): + # [n_agent, n_step, hidden_dim] + _feat_temporal = feat_a if i == 0 else feat_a_t_dict[i] + + if t == 0: # init, process hist_step together + _feat_temporal = self.t_attn_layers[i]( + _feat_temporal.flatten(0, 1), r_t, edge_index_t + ).view(n_agent, n_step, -1) + _feat_temporal = _feat_temporal.transpose(0, 1).flatten(0, 1) + + if map_feature is not None: + # [hist_step*n_pl, hidden_dim] + _feat_map = ( + map_feature["pt_token"] + .unsqueeze(0) + .expand(hist_step, -1, -1) + .flatten(0, 1) + ) + _feat_temporal = self.pt2a_attn_layers[i]( + (_feat_map, _feat_temporal), r_pl2a, edge_index_pl2a + ) + + _feat_temporal = self.a2a_attn_layers[i]( + _feat_temporal, r_a2a, edge_index_a2a + ) + _feat_temporal = _feat_temporal.view(n_step, n_agent, -1).transpose( + 0, 1 + ) + feat_a_now = _feat_temporal[:, -1] # [n_agent, hidden_dim] + + if i + 1 < self.num_layers: + feat_a_t_dict[i + 1] = _feat_temporal + + else: # process one step + feat_a_now = self.t_attn_layers[i]( + (_feat_temporal.flatten(0, 1), _feat_temporal[:, -1]), + r_t, + edge_index_t, + ) + + if map_feature is not None: + feat_a_now = self.pt2a_attn_layers[i]( + (map_feature["pt_token"], feat_a_now), + r_pl2a, + edge_index_pl2a, + ) + feat_a_now = self.a2a_attn_layers[i]( + feat_a_now, r_a2a, edge_index_a2a + ) + + # [n_agent, n_step, hidden_dim] + if i + 1 < self.num_layers: + feat_a_t_dict[i + 1] = torch.cat( + (feat_a_t_dict[i + 1], feat_a_now.unsqueeze(1)), dim=1 + ) + + # ! get outputs + next_token_logits = self.token_predict_head(feat_a_now) + next_token_logits_list.append(next_token_logits) # [n_agent, n_token] + + sampling_args = dict( + token_traj=tokenized_agent["token_traj"], + token_traj_all=tokenized_agent["token_traj_all"], + sampling_scheme=sampling_scheme, + # ! for most-likely sampling + next_token_logits=next_token_logits, + # ! for nearest-pos sampling + pos_now=pos_a[:, t_now], # [n_agent, 2] + head_now=head_a[:, t_now], # [n_agent] + ) + if sampling_scheme.criterium != "topk_prob": + sampling_args.update( + dict( + pos_next_gt=tokenized_agent["gt_pos_raw"][ + :, n_step + ], # [n_agent, 2] + head_next_gt=tokenized_agent["gt_head_raw"][ + :, n_step + ], # [n_agent] + valid_next_gt=tokenized_agent["gt_valid_raw"][ + :, n_step + ], # [n_agent] + token_agent_shape=tokenized_agent[ + "token_agent_shape" + ], # [n_token, 2] + ) + ) + + # next_token_idx: [n_agent], next_token_traj_all: [n_agent, 6, 4, 2] + next_token_idx, next_token_traj_all, sample_logits = sample_next_token_traj( + **sampling_args + ) + + sample_logits_list.append(sample_logits) + pred_idx_list.append(next_token_idx) + + token_traj_global = transform_to_global( + pos_local=next_token_traj_all.flatten(1, 2), # [n_agent, 6*4, 2] + head_local=None, + pos_now=pos_a[:, t_now], # [n_agent, 2] + head_now=head_a[:, t_now], # [n_agent] + )[0].view(*next_token_traj_all.shape) + + if not self.training: + pred_traj_10hz[:, t * 5 : (t + 1) * 5] = token_traj_global[:, 1:].mean( + 2 + ) + diff_xy = token_traj_global[:, 1:, 0] - token_traj_global[:, 1:, 3] + pred_head_10hz[:, t * 5 : (t + 1) * 5] = torch.arctan2( + diff_xy[:, :, 1], diff_xy[:, :, 0] + ) + + pred_valid_10hz[:, t * 5 : (t + 1) * 5] = pred_valid[ + :, t_now + ].unsqueeze(-1) + + # ! get pos_a_next and head_a_next, spawn unseen agents + pos_a_next = token_traj_global[:, -1].mean(dim=1) + diff_xy_next = token_traj_global[:, -1, 0] - token_traj_global[:, -1, 3] + head_a_next = torch.arctan2(diff_xy_next[:, 1], diff_xy_next[:, 0]) + + # ! update tensors for for next step + pred_valid[:, n_step] = pred_valid[:, t_now] + + # ! Override positions/headings with provided trajectory if fixed agent mask is provided + if freeze_agent_future: + # Get ego position, heading, and validity from provided trajectory + + agent_pos_next = freeze_tokenized_agent["gt_pos_raw"][ + :, t + ] # [n_agent, 2] + agent_head_next = freeze_tokenized_agent["gt_head_raw"][ + :, t + ] # [n_agent] + agent_valid_next = freeze_tokenized_agent["gt_valid_raw"][ + :, t + ] # [n_agent] + agent_next_token_idx = freeze_tokenized_agent["gt_idx"][:, t] + + # Override ego predictions with provided trajectory (for next step context) + pos_a_next[freeze_agent_mask] = agent_pos_next + head_a_next[freeze_agent_mask] = agent_head_next + + next_token_idx[freeze_agent_mask] = agent_next_token_idx + + pred_valid[freeze_agent_mask, n_step] = agent_valid_next + + pos_a = torch.cat([pos_a, pos_a_next.unsqueeze(1)], dim=1) + head_a = torch.cat([head_a, head_a_next.unsqueeze(1)], dim=1) + head_vector_a_next = torch.stack( + [head_a_next.cos(), head_a_next.sin()], dim=-1 + ) + head_vector_a = torch.cat( + [head_vector_a, head_vector_a_next.unsqueeze(1)], dim=1 + ) + + # ! get agent_token_emb_next + agent_token_emb_next = torch.zeros_like(agent_token_emb[:, 0]) + agent_token_emb_next[veh_mask] = agent_token_emb_veh[ + next_token_idx[veh_mask] + ] + agent_token_emb_next[ped_mask] = agent_token_emb_ped[ + next_token_idx[ped_mask] + ] + agent_token_emb_next[cyc_mask] = agent_token_emb_cyc[ + next_token_idx[cyc_mask] + ] + agent_token_emb = torch.cat( + [agent_token_emb, agent_token_emb_next.unsqueeze(1)], dim=1 + ) + + # ! get feat_a_next + motion_vector_a = pos_a[:, -1] - pos_a[:, -2] # [n_agent, 2] + x_a = torch.stack( + [ + torch.norm(motion_vector_a, p=2, dim=-1), + angle_between_2d_vectors( + ctr_vector=head_vector_a[:, -1], nbr_vector=motion_vector_a + ), + ], + dim=-1, + ) + # [n_agent, hidden_dim] + x_a = self.x_a_emb(continuous_inputs=x_a, categorical_embs=categorical_embs) + # [n_agent, 1, 2*hidden_dim] + feat_a_next = torch.cat((agent_token_emb_next, x_a), dim=-1).unsqueeze(1) + feat_a_next = self.fusion_emb(feat_a_next) + feat_a = torch.cat([feat_a, feat_a_next], dim=1) + + out_dict = { + # action that goes from [(10->15), ..., (85->90)] + "next_token_logits": torch.stack(next_token_logits_list, dim=1), + "next_token_valid": pred_valid[:, step_current_2hz:], # [n_agent, 16] + # for step {5, 10, ..., 90} and act [(0->5), (5->10), ..., (85->90)] + "pred_pos": pos_a, # [n_agent, 18, 2] + "pred_head": head_a, # [n_agent, 18] + "pred_valid": pred_valid, # [n_agent, 18] + # for step {5, 10, ..., 90} + "gt_pos_raw": tokenized_agent["gt_pos_raw"], # [n_agent, 18, 2] + "gt_head_raw": tokenized_agent["gt_head_raw"], # [n_agent, 18] + "gt_valid_raw": tokenized_agent["gt_valid_raw"], # [n_agent, 18] + # or use the tokenized gt + "gt_pos": tokenized_agent["gt_pos"], # [n_agent, 18, 2] + "gt_head": tokenized_agent["gt_heading"], # [n_agent, 18] + "gt_valid": tokenized_agent["valid_mask"], # [n_agent, 18] + # [n_agent, 16, n_token] + "sample_logits": torch.stack(sample_logits_list, dim=1), + "pred_idx": torch.stack(pred_idx_list, dim=1), # [n_agent, 16] + } + + if not self.training: # 10hz predictions for runtime inference + out_dict["pred_traj_10hz"] = pred_traj_10hz + out_dict["pred_head_10hz"] = pred_head_10hz + pred_z = tokenized_agent["gt_z_raw"].unsqueeze(1) # [n_agent, 1] + out_dict["pred_z_10hz"] = pred_z.expand(-1, pred_traj_10hz.shape[1]) + out_dict["pred_valid_10hz"] = pred_valid_10hz + + return out_dict diff --git a/src/trafficsim/alpasim_trafficsim/catk/smart/modules/map_decoder.py b/src/trafficsim/alpasim_trafficsim/catk/smart/modules/map_decoder.py new file mode 100644 index 00000000..38b141bb --- /dev/null +++ b/src/trafficsim/alpasim_trafficsim/catk/smart/modules/map_decoder.py @@ -0,0 +1,117 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation + +from typing import Dict + +import torch +import torch.nn as nn +from alpasim_trafficsim.catk.smart.layers.attention_layer import AttentionLayer +from alpasim_trafficsim.catk.smart.layers.fourier_embedding import ( + FourierEmbedding, + MLPEmbedding, +) +from alpasim_trafficsim.catk.smart.utils import ( + angle_between_2d_vectors, + weight_init, + wrap_angle, +) +from torch_cluster import radius_graph + + +class SMARTMapDecoder(nn.Module): + def __init__( + self, + hidden_dim: int, + pl2pl_radius: float, + num_freq_bands: int, + num_layers: int, + num_heads: int, + head_dim: int, + dropout: float, + num_polyline_types: int, + num_polygon_types: int, + num_light_types: int, + num_polyline_points: int, + ) -> None: + super(SMARTMapDecoder, self).__init__() + self.pl2pl_radius = pl2pl_radius + self.num_layers = num_layers + + self.type_pt_emb = nn.Embedding(num_polyline_types, hidden_dim) + self.polygon_type_emb = nn.Embedding(num_polygon_types, hidden_dim) + self.light_pl_emb = nn.Embedding(num_light_types, hidden_dim) + + input_dim_r_pt2pt = 3 + self.r_pt2pt_emb = FourierEmbedding( + input_dim=input_dim_r_pt2pt, + hidden_dim=hidden_dim, + num_freq_bands=num_freq_bands, + ) + self.pt2pt_layers = nn.ModuleList( + [ + AttentionLayer( + hidden_dim=hidden_dim, + num_heads=num_heads, + head_dim=head_dim, + dropout=dropout, + bipartite=False, + has_pos_emb=True, + ) + for _ in range(num_layers) + ] + ) + + # map_token_traj_src: [n_token, 11, 2].flatten(0,1) + # old: 22=11*2 + input_dim = num_polyline_points * 2 + self.token_emb = MLPEmbedding(input_dim=input_dim, hidden_dim=hidden_dim) + self.apply(weight_init) + + def forward(self, tokenized_map: Dict | None) -> Dict[str, torch.Tensor] | None: + if tokenized_map is None: + return None + + pos_pt = tokenized_map["position"] + orient_pt = tokenized_map["orientation"] + orient_vector_pt = torch.stack([orient_pt.cos(), orient_pt.sin()], dim=-1) + pt_token_emb_src = self.token_emb(tokenized_map["token_traj_src"]) + x_pt = pt_token_emb_src[tokenized_map["token_idx"]] + + x_pt_categorical_embs = [ + self.type_pt_emb(tokenized_map["type"]), + self.polygon_type_emb(tokenized_map["pl_type"]), + self.light_pl_emb(tokenized_map["light_type"]), + ] + x_pt = x_pt + torch.stack(x_pt_categorical_embs).sum(dim=0) + edge_index_pt2pt = radius_graph( + x=pos_pt, + r=self.pl2pl_radius, + batch=tokenized_map["batch"], + loop=False, + max_num_neighbors=100, + ) + rel_pos_pt2pt = pos_pt[edge_index_pt2pt[0]] - pos_pt[edge_index_pt2pt[1]] + rel_orient_pt2pt = wrap_angle( + orient_pt[edge_index_pt2pt[0]] - orient_pt[edge_index_pt2pt[1]] + ) + r_pt2pt = torch.stack( + [ + torch.norm(rel_pos_pt2pt[:, :2], p=2, dim=-1), + angle_between_2d_vectors( + ctr_vector=orient_vector_pt[edge_index_pt2pt[1]], + nbr_vector=rel_pos_pt2pt[:, :2], + ), + rel_orient_pt2pt, + ], + dim=-1, + ) + r_pt2pt = self.r_pt2pt_emb(continuous_inputs=r_pt2pt, categorical_embs=None) + for i in range(self.num_layers): + x_pt = self.pt2pt_layers[i](x_pt, r_pt2pt, edge_index_pt2pt) + + return { + "pt_token": x_pt, + "position": pos_pt, + "orientation": orient_pt, + "batch": tokenized_map["batch"], + } diff --git a/src/trafficsim/alpasim_trafficsim/catk/smart/modules/smart_decoder.py b/src/trafficsim/alpasim_trafficsim/catk/smart/modules/smart_decoder.py new file mode 100644 index 00000000..fac6ddec --- /dev/null +++ b/src/trafficsim/alpasim_trafficsim/catk/smart/modules/smart_decoder.py @@ -0,0 +1,65 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation + +from typing import Optional + +import torch.nn as nn + +from .agent_decoder import SMARTAgentDecoder +from .map_decoder import SMARTMapDecoder + + +class SMARTDecoder(nn.Module): + def __init__( + self, + hidden_dim: int, + num_historical_steps: int, + num_future_steps: int, + pl2pl_radius: float, + time_span: Optional[int], + pl2a_radius: float, + a2a_radius: float, + num_freq_bands: int, + num_map_layers: int, + num_agent_layers: int, + num_heads: int, + head_dim: int, + dropout: float, + hist_drop_prob: float, + n_token_agent: int, + num_agent_types: int, + num_polyline_types: int, + num_polygon_types: int, + num_light_types: int, + num_polyline_points: int, + ) -> None: + super(SMARTDecoder, self).__init__() + self.map_encoder = SMARTMapDecoder( + hidden_dim=hidden_dim, + pl2pl_radius=pl2pl_radius, + num_freq_bands=num_freq_bands, + num_layers=num_map_layers, + num_heads=num_heads, + head_dim=head_dim, + dropout=dropout, + num_polyline_types=num_polyline_types, + num_polygon_types=num_polygon_types, + num_light_types=num_light_types, + num_polyline_points=num_polyline_points, + ) + self.agent_encoder = SMARTAgentDecoder( + hidden_dim=hidden_dim, + num_historical_steps=num_historical_steps, + num_future_steps=num_future_steps, + time_span=time_span, + pl2a_radius=pl2a_radius, + a2a_radius=a2a_radius, + num_freq_bands=num_freq_bands, + num_layers=num_agent_layers, + num_heads=num_heads, + head_dim=head_dim, + dropout=dropout, + hist_drop_prob=hist_drop_prob, + n_token_agent=n_token_agent, + num_agent_types=num_agent_types, + ) diff --git a/src/trafficsim/alpasim_trafficsim/catk/smart/tokens/__init__.py b/src/trafficsim/alpasim_trafficsim/catk/smart/tokens/__init__.py new file mode 100644 index 00000000..75d30d89 --- /dev/null +++ b/src/trafficsim/alpasim_trafficsim/catk/smart/tokens/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation diff --git a/src/trafficsim/alpasim_trafficsim/catk/smart/tokens/token_processor.py b/src/trafficsim/alpasim_trafficsim/catk/smart/tokens/token_processor.py new file mode 100644 index 00000000..35d0d017 --- /dev/null +++ b/src/trafficsim/alpasim_trafficsim/catk/smart/tokens/token_processor.py @@ -0,0 +1,453 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation + +import os +import pickle +import random +from typing import Dict, Tuple + +import torch +from alpasim_trafficsim.catk.smart.utils import ( + cal_polygon_contour, + transform_to_global, + transform_to_local, + wrap_angle, +) +from omegaconf import DictConfig +from torch import Tensor +from torch.distributions import Categorical + + +class TokenProcessor(torch.nn.Module): + def __init__( + self, + map_token_file: str, + agent_token_file: str, + map_token_sampling: DictConfig, + agent_token_sampling: DictConfig, + time_step: float, # 0.1 or 0.02 + map_dropout_prob: float = 0.0, + ) -> None: + super(TokenProcessor, self).__init__() + self.map_token_sampling = map_token_sampling + self.agent_token_sampling = agent_token_sampling + self.shift = 5 + self.time_step = time_step + self.map_dropout_prob = map_dropout_prob + + self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + + module_dir = os.path.dirname(__file__) + self.init_agent_token(os.path.join(module_dir, agent_token_file)) + self.init_map_token(os.path.join(module_dir, map_token_file)) + self.n_token_agent = self.agent_token_all_veh.shape[0] + + @torch.no_grad() + def forward( + self, + data: Dict, + apply_heading_correction: bool, + apply_boundary_extrapolation: bool, + ) -> Tuple[ + Dict[str, Tensor] | None, + Dict[str, Tensor], + Dict[str, Tensor], + Dict | None, + ]: + tokenized_map, rb_data = self.tokenize_map(data) + + tokenized_agent, agent_data = self.tokenize_agent( + data, + apply_heading_correction=apply_heading_correction, + apply_boundary_extrapolation=apply_boundary_extrapolation, + ) + return tokenized_map, tokenized_agent, agent_data, rb_data + + def init_map_token(self, map_token_traj_path, argmin_sample_len=3) -> None: + map_token_traj = pickle.load(open(map_token_traj_path, "rb"))["traj_src"] + indices = torch.linspace( + 0, map_token_traj.shape[1] - 1, steps=argmin_sample_len + ).long() + + self.register_buffer( + "map_token_traj_src", + torch.tensor( + map_token_traj, dtype=torch.float32, device=self.device + ).flatten(1, 2), + persistent=False, + ) # [n_token, 11*2] + + self.register_buffer( + "map_token_sample_pt", + torch.tensor( + map_token_traj[:, indices], dtype=torch.float32, device=self.device + ).unsqueeze(0), + persistent=False, + ) # [1, n_token, 3, 2] + + def init_agent_token(self, agent_token_path) -> None: + agent_token_data = pickle.load(open(agent_token_path, "rb")) + for k, v in agent_token_data["token_all"].items(): + v = torch.tensor(v, dtype=torch.float32, device=self.device) + # [n_token, 6, 4, 2], countour, 10 hz + self.register_buffer(f"agent_token_all_{k}", v, persistent=False) + + def tokenize_map( + self, data: Dict + ) -> tuple[Dict[str, Tensor] | None, list[Tensor] | None]: + traj_pos = data["map_save"]["traj_pos"] + traj_theta = data["map_save"]["traj_theta"] + polyline_extras = data["pt_token"] + rb_data = data["rb_data"] if "rb_data" in data else None + + out_type = polyline_extras["type"].long() # [n_pl] + out_pl_type = polyline_extras["pl_type"].long() # [n_pl] + out_light_type = polyline_extras["light_type"].long() # [n_pl] + out_batch = polyline_extras["batch"] # [n_pl] + + if ( + self.training + and self.map_dropout_prob > 0.0 + and random.uniform(0, 1) < self.map_dropout_prob + ): + return None, None + + if traj_pos is None: + return None, None + + traj_pos_local, _ = transform_to_local( + pos_global=traj_pos, # [n_pl, 3, 2] + head_global=None, # [n_pl, 1] + pos_now=traj_pos[:, 0], # [n_pl, 2] + head_now=traj_theta, # [n_pl] + ) + # [1, n_token, 3, 2] - [n_pl, 1, 3, 2] + dist = torch.sum( + (self.map_token_sample_pt - traj_pos_local.unsqueeze(1)) ** 2, + dim=(-2, -1), + ) # [n_pl, n_token] + + if self.training and (self.map_token_sampling.num_k > 1): + topk_dists, topk_indices = torch.topk( + dist, + self.map_token_sampling.num_k, + dim=-1, + largest=False, + sorted=False, + ) # [n_pl, K] + + topk_logits = (-1e-6 - topk_dists) / self.map_token_sampling.temp + _samples = Categorical(logits=topk_logits).sample() # [n_pl] in K + token_idx = topk_indices[torch.arange(len(_samples)), _samples].contiguous() + else: + token_idx = torch.argmin(dist, dim=-1) + + tokenized_map = { + "position": traj_pos[:, 0].contiguous(), # [n_pl, 2] + "orientation": traj_theta, # [n_pl] + "token_idx": token_idx, # [n_pl] + "token_traj_src": self.map_token_traj_src, # [n_token, 11*2], [n_token, 3*2] + "type": out_type, # [n_pl] + "pl_type": out_pl_type, # [n_pl] + "light_type": out_light_type, # [n_pl] + "batch": out_batch, # [n_pl] + } + + return tokenized_map, rb_data + + def tokenize_agent( + self, + data: Dict, + apply_heading_correction: bool, + apply_boundary_extrapolation: bool, + ) -> tuple[Dict[str, Tensor], Dict[str, Tensor]]: + """ + Args: data["agent"]: Dict + "valid_mask": [n_agent, n_step], bool + "role": [n_agent, 3], bool + "id": [n_agent], int64 + "type": [n_agent], uint8 + "position": [n_agent, n_step, 3], float32 + "heading": [n_agent, n_step], float32 + "velocity": [n_agent, n_step, 2], float32 + "shape": [n_agent, 3], float32 + """ + agent_data = data["agent"] + num_graphs = ( + data.num_graphs if hasattr(data, "num_graphs") else data["num_graphs"] + ) + + # ! collate width/length, traj tokens for current batch + agent_shape, real_agent_shape, token_traj_all, token_traj = ( + self._get_agent_shape_and_token_traj( + agent_data["type"], + agent_data["shape"], + ) + ) + + # ! get raw trajectory data + valid = agent_data["valid_mask"].clone() # [n_agent, n_step] + heading = agent_data["heading"].clone() # [n_agent, n_step] + pos = ( + agent_data["position"][..., :2].contiguous().clone() + ) # [n_agent, n_step, 2] + vel = agent_data["velocity"].clone() # [n_agent, n_step, 2] + + # ! agent, specifically vehicle's heading can be 180 degree off. We fix it here. + if apply_heading_correction: + heading = self._clean_heading(valid, heading) + + # ! extrapolate to previous motion boundary (5th step) because the valid mask may not align. + if apply_boundary_extrapolation: + valid, pos, heading, vel = self._extrapolate_agent_to_prev_token_step( + valid, + pos, + heading, + vel, + time_step=self.time_step, + ) + + # ! prepare output dict + tokenized_agent = { + "num_graphs": num_graphs, + "type": agent_data["type"], + "shape": agent_data["shape"], + "ego_mask": agent_data["role"][:, 0], # [n_agent] + "token_agent_shape": agent_shape, # [n_agent, 2] + "real_agent_shape": real_agent_shape, # [n_agent, 2] + "batch": agent_data["batch"], + "token_traj_all": token_traj_all, # [n_agent, n_token, 6, 4, 2] + "token_traj": token_traj, # [n_agent, n_token, 4, 2] + # for step {5, 10, ..., 90} + "gt_pos_raw": pos[:, self.shift :: self.shift], # [n_agent, n_step, 2] + "gt_head_raw": heading[:, self.shift :: self.shift], # [n_agent, n_step] + "gt_valid_raw": valid[:, self.shift :: self.shift], # [n_agent, n_step] + } + + # [n_token, 8] + for k in ["veh", "ped", "cyc"]: + tokenized_agent[f"trajectory_token_{k}"] = getattr( + self, f"agent_token_all_{k}" + )[:, -1].flatten(1, 2) + + # ! match token for each agent + if not self.training: + # [n_agent] + tokenized_agent["gt_z_raw"] = torch.zeros( + (agent_data["position"].shape[0],), + dtype=agent_data["position"].dtype, + device=agent_data["position"].device, + ) + + token_dict = self._match_agent_token( + valid=valid, + pos=pos, + heading=heading, + agent_shape=agent_shape, + token_traj=token_traj, + ) + tokenized_agent.update(token_dict) + return tokenized_agent, agent_data + + def _match_agent_token( + self, + valid: Tensor, # [n_agent, n_step] + pos: Tensor, # [n_agent, n_step, 2] + heading: Tensor, # [n_agent, n_step] + agent_shape: Tensor, # [n_agent, 2] + token_traj: Tensor, # [n_agent, n_token, 4, 2] + ) -> Dict[str, Tensor]: + """n_step_token=n_step//5 + n_step_token=18 for train with BC. + n_step_token=2 for val/test and train with closed-loop rollout. + Returns: Dict + # ! action that goes from [(0->5), (5->10), ..., (85->90)] + "valid_mask": [n_agent, n_step_token] + "gt_idx": [n_agent, n_step_token] + # ! at step [5, 10, 15, ..., 90] + "gt_pos": [n_agent, n_step_token, 2] + "gt_heading": [n_agent, n_step_token] + # ! noisy sampling for training data augmentation + "sampled_idx": [n_agent, n_step_token] + "sampled_pos": [n_agent, n_step_token, 2] + "sampled_heading": [n_agent, n_step_token] + """ + num_k = self.agent_token_sampling.num_k if self.training else 1 + n_agent, n_step = valid.shape + range_a = torch.arange(n_agent) + + out_dict = { + "valid_mask": [], # valid or not [n_agent, n_step_token] + "gt_idx": [], # matched token index [n_agent, n_step_token] + "gt_pos": [], # matched token's averaged position [n_agent, n_step_token, 2] + "gt_heading": [], # matched token's position [n_agent, n_step_token] + "sampled_idx": [], + "sampled_pos": [], + "sampled_heading": [], + } + + # matching start from the first token i=0 + prev_pos, prev_head = pos[:, 0], heading[:, 0] # [n_agent, 2], [n_agent] + prev_pos_sample, prev_head_sample = pos[:, 0], heading[:, 0] + + for i in range( + self.shift, n_step, self.shift + ): # next token t=[5, 10, 15, ..., 90] + _valid_mask = valid[:, i - self.shift] & valid[:, i] # [n_agent] + _invalid_mask = ~_valid_mask + out_dict["valid_mask"].append(_valid_mask) + + # gt_contour: [n_agent, 4, 2] in global coord + gt_contour = cal_polygon_contour(pos[:, i], heading[:, i], agent_shape) + gt_contour = gt_contour.unsqueeze(1) # [n_agent, 1, 4, 2] + + # ! tokenize without sampling + token_world_gt = transform_to_global( + pos_local=token_traj.flatten(1, 2), # [n_agent, n_token*4, 2] + head_local=None, + pos_now=prev_pos, # [n_agent, 2] + head_now=prev_head, # [n_agent] + )[0].view(*token_traj.shape) + + token_idx_gt = torch.argmin( + torch.norm(token_world_gt - gt_contour, dim=-1).sum(-1), dim=-1 + ) # [n_agent] + # [n_agent, 4, 2] + token_contour_gt = token_world_gt[range_a, token_idx_gt] + + # udpate prev_pos, prev_head + prev_head = heading[:, i].clone() + dxy = token_contour_gt[:, 0] - token_contour_gt[:, 3] + prev_head[_valid_mask] = torch.arctan2(dxy[:, 1], dxy[:, 0])[_valid_mask] + + prev_pos = pos[:, i].clone() + prev_pos[_valid_mask] = token_contour_gt.mean(1)[_valid_mask] + + # add to output dict + out_dict["gt_idx"].append(token_idx_gt) + out_dict["gt_pos"].append( + prev_pos.masked_fill(_invalid_mask.unsqueeze(1), 0) + ) + out_dict["gt_heading"].append(prev_head.masked_fill(_invalid_mask, 0)) + + # ! tokenize from sampled rollout state + if num_k == 1: # K=1 means no sampling + out_dict["sampled_idx"].append(out_dict["gt_idx"][-1]) + out_dict["sampled_pos"].append(out_dict["gt_pos"][-1]) + out_dict["sampled_heading"].append(out_dict["gt_heading"][-1]) + else: + # contour: [n_agent, n_token, 4, 2], 2HZ, global coord + token_world_sample = transform_to_global( + pos_local=token_traj.flatten(1, 2), # [n_agent, n_token*4, 2] + head_local=None, + pos_now=prev_pos_sample, # [n_agent, 2] + head_now=prev_head_sample, # [n_agent] + )[0].view(*token_traj.shape) + + # dist: [n_agent, n_token] + dist = torch.norm(token_world_sample - gt_contour, dim=-1).mean(-1) + topk_dists, topk_indices = torch.topk( + dist, num_k, dim=-1, largest=False, sorted=False + ) # [n_agent, K] + + topk_logits = (-1.0 * topk_dists) / self.agent_token_sampling.temp + _samples = Categorical(logits=topk_logits).sample() # [n_agent] in K + token_idx_sample = topk_indices[range_a, _samples] + token_contour_sample = token_world_sample[range_a, token_idx_sample] + + # udpate prev_pos_sample, prev_head_sample + prev_head_sample = heading[:, i].clone() + dxy = token_contour_sample[:, 0] - token_contour_sample[:, 3] + prev_head_sample[_valid_mask] = torch.arctan2(dxy[:, 1], dxy[:, 0])[ + _valid_mask + ] + prev_pos_sample = pos[:, i].clone() + prev_pos_sample[_valid_mask] = token_contour_sample.mean(1)[_valid_mask] + # add to output dict + out_dict["sampled_idx"].append(token_idx_sample) + out_dict["sampled_pos"].append( + prev_pos_sample.masked_fill(_invalid_mask.unsqueeze(1), 0.0) + ) + out_dict["sampled_heading"].append( + prev_head_sample.masked_fill(_invalid_mask, 0.0) + ) + out_dict = {k: torch.stack(v, dim=1) for k, v in out_dict.items()} + + return out_dict + + @staticmethod + def _clean_heading(valid: Tensor, heading: Tensor) -> Tensor: + valid_pairs = valid[:, :-1] & valid[:, 1:] + for i in range(heading.shape[1] - 1): + heading_diff = torch.abs(wrap_angle(heading[:, i] - heading[:, i + 1])) + change_needed = (heading_diff > 1.5) & valid_pairs[:, i] + heading[:, i + 1][change_needed] = heading[:, i][change_needed] + return heading + + def _extrapolate_agent_to_prev_token_step( + self, + valid: Tensor, # [n_agent, n_step] + pos: Tensor, # [n_agent, n_step, 2] + heading: Tensor, # [n_agent, n_step] + vel: Tensor, # [n_agent, n_step, 2] + time_step: float, # 0.1 or 0.02 + ) -> Tuple[Tensor, Tensor, Tensor, Tensor]: + # [n_agent], max will give the first True step + first_valid_step = torch.max(valid, dim=1).indices + + for i, t in enumerate(first_valid_step): # extrapolate to previous 5th step. + n_step_to_extrapolate = t % self.shift + # TODO(bivanovic): This is a magic number hack to ensure that the agent is always visible at the first token + if (t == 15) and (not valid[i, 15 - self.shift]): + # such that at least one token is valid in the history. + n_step_to_extrapolate = self.shift + + if n_step_to_extrapolate > 0: + vel[i, t - n_step_to_extrapolate : t] = vel[i, t] + valid[i, t - n_step_to_extrapolate : t] = True + heading[i, t - n_step_to_extrapolate : t] = heading[i, t] + + for j in range(n_step_to_extrapolate): + # TODO(bivanovic): This is a magic number hack (assuming 10 Hz uniformly). + pos[i, t - j - 1] = pos[i, t - j] - vel[i, t] * time_step + + return valid, pos, heading, vel + + def _get_agent_shape_and_token_traj( + self, agent_type: Tensor, agent_shape: Tensor + ) -> Tuple[Tensor, Tensor, Tensor, Tensor]: + """ + agent_shape: [n_agent, 2] + token_traj_all: [n_agent, n_token, 6, 4, 2] + token_traj: [n_agent, n_token, 4, 2] + """ + agent_type_masks = { + "veh": agent_type == 0, + "ped": agent_type == 1, + "cyc": agent_type == 2, + } + agent_shape_out = 0.0 + real_agent_shape = 0.0 + token_traj_all = 0.0 + real_length, real_width = agent_shape[:, 0], agent_shape[:, 1] + for k, mask in agent_type_masks.items(): + if k == "veh": + width = 2.0 + length = 4.8 + elif k == "cyc": + width = 1.0 + length = 2.0 + else: + width = 1.0 + length = 1.0 + agent_shape_out += torch.stack([width * mask, length * mask], dim=-1) + real_agent_shape += torch.stack( + [real_width * mask, real_length * mask], dim=-1 + ) + + token_traj_all += mask[:, None, None, None, None] * ( + getattr(self, f"agent_token_all_{k}").unsqueeze(0) + ) + + token_traj = token_traj_all[:, :, -1, :, :].contiguous() + return agent_shape_out, real_agent_shape, token_traj_all, token_traj diff --git a/src/trafficsim/alpasim_trafficsim/catk/smart/utils/__init__.py b/src/trafficsim/alpasim_trafficsim/catk/smart/utils/__init__.py new file mode 100644 index 00000000..84de7c19 --- /dev/null +++ b/src/trafficsim/alpasim_trafficsim/catk/smart/utils/__init__.py @@ -0,0 +1,21 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation + +from .geometry import angle_between_2d_vectors, wrap_angle +from .rollout import ( + cal_polygon_contour, + sample_next_token_traj, + transform_to_global, + transform_to_local, +) +from .weight_init import weight_init + +__all__ = [ + "angle_between_2d_vectors", + "cal_polygon_contour", + "sample_next_token_traj", + "transform_to_global", + "transform_to_local", + "weight_init", + "wrap_angle", +] diff --git a/src/trafficsim/alpasim_trafficsim/catk/smart/utils/geometry.py b/src/trafficsim/alpasim_trafficsim/catk/smart/utils/geometry.py new file mode 100644 index 00000000..a9e38323 --- /dev/null +++ b/src/trafficsim/alpasim_trafficsim/catk/smart/utils/geometry.py @@ -0,0 +1,22 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation + +import math + +import torch + + +def angle_between_2d_vectors( + ctr_vector: torch.Tensor, nbr_vector: torch.Tensor +) -> torch.Tensor: + return torch.atan2( + ctr_vector[..., 0] * nbr_vector[..., 1] + - ctr_vector[..., 1] * nbr_vector[..., 0], + (ctr_vector[..., :2] * nbr_vector[..., :2]).sum(dim=-1), + ) + + +def wrap_angle( + angle: torch.Tensor, min_val: float = -math.pi, max_val: float = math.pi +) -> torch.Tensor: + return min_val + (angle + max_val) % (max_val - min_val) diff --git a/src/trafficsim/alpasim_trafficsim/catk/smart/utils/rollout.py b/src/trafficsim/alpasim_trafficsim/catk/smart/utils/rollout.py new file mode 100644 index 00000000..da393e7c --- /dev/null +++ b/src/trafficsim/alpasim_trafficsim/catk/smart/utils/rollout.py @@ -0,0 +1,179 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation + +from typing import Optional, Tuple + +import torch +from omegaconf import DictConfig +from torch import Tensor +from torch.distributions import Categorical + + +@torch.no_grad() +def cal_polygon_contour( + pos: Tensor, # [n_agent, n_step, n_target, 2] + head: Tensor, # [n_agent, n_step, n_target] + width_length: Tensor, # [n_agent, 1, 1, 2] +) -> Tensor: # [n_agent, n_step, n_target, 4, 2] + x, y = pos[..., 0], pos[..., 1] # [n_agent, n_step, n_target] + width, length = width_length[..., 0], width_length[..., 1] # [n_agent, 1 ,1] + + half_cos = 0.5 * head.cos() # [n_agent, n_step, n_target] + half_sin = 0.5 * head.sin() # [n_agent, n_step, n_target] + length_cos = length * half_cos # [n_agent, n_step, n_target] + length_sin = length * half_sin # [n_agent, n_step, n_target] + width_cos = width * half_cos # [n_agent, n_step, n_target] + width_sin = width * half_sin # [n_agent, n_step, n_target] + + left_front_x = x + length_cos - width_sin + left_front_y = y + length_sin + width_cos + left_front = torch.stack((left_front_x, left_front_y), dim=-1) + + right_front_x = x + length_cos + width_sin + right_front_y = y + length_sin - width_cos + right_front = torch.stack((right_front_x, right_front_y), dim=-1) + + right_back_x = x - length_cos + width_sin + right_back_y = y - length_sin - width_cos + right_back = torch.stack((right_back_x, right_back_y), dim=-1) + + left_back_x = x - length_cos - width_sin + left_back_y = y - length_sin + width_cos + left_back = torch.stack((left_back_x, left_back_y), dim=-1) + + polygon_contour = torch.stack( + (left_front, right_front, right_back, left_back), dim=-2 + ) + + return polygon_contour + + +def transform_to_global( + pos_local: Tensor, # [n_agent, n_step, 2] + head_local: Optional[Tensor], # [n_agent, n_step] + pos_now: Tensor, # [n_agent, 2] + head_now: Tensor, # [n_agent] +) -> Tuple[Tensor, Optional[Tensor]]: + cos, sin = head_now.cos(), head_now.sin() + rot_mat = torch.zeros((head_now.shape[0], 2, 2), device=head_now.device) + rot_mat[:, 0, 0] = cos + rot_mat[:, 0, 1] = sin + rot_mat[:, 1, 0] = -sin + rot_mat[:, 1, 1] = cos + + pos_global = torch.bmm(pos_local, rot_mat) # [n_agent, n_step, 2]*[n_agent, 2, 2] + pos_global = pos_global + pos_now.unsqueeze(1) + if head_local is None: + head_global = None + else: + head_global = head_local + head_now.unsqueeze(1) + return pos_global, head_global + + +def transform_to_local( + pos_global: Tensor, # [n_agent, n_step, 2] + head_global: Optional[Tensor], # [n_agent, n_step] + pos_now: Tensor, # [n_agent, 2] + head_now: Tensor, # [n_agent] +) -> Tuple[Tensor, Optional[Tensor]]: + cos, sin = head_now.cos(), head_now.sin() + rot_mat = torch.zeros((head_now.shape[0], 2, 2), device=head_now.device) + rot_mat[:, 0, 0] = cos + rot_mat[:, 0, 1] = -sin + rot_mat[:, 1, 0] = sin + rot_mat[:, 1, 1] = cos + + pos_local = pos_global - pos_now.unsqueeze(1) + pos_local = torch.bmm(pos_local, rot_mat) # [n_agent, n_step, 2]*[n_agent, 2, 2] + if head_global is None: + head_local = None + else: + head_local = head_global - head_now.unsqueeze(1) + return pos_local, head_local + + +def sample_next_token_traj( + token_traj: Tensor, # [n_agent, n_token, 4, 2] + token_traj_all: Tensor, # [n_agent, n_token, 6, 4, 2] + sampling_scheme: DictConfig, + # ! for most-likely sampling + next_token_logits: Tensor, # [n_agent, n_token], with grad + # ! for nearest-pos sampling, sampling near to GT + pos_now: Tensor, # [n_agent, 2] + head_now: Tensor, # [n_agent] + pos_next_gt: Tensor | None = None, # [n_agent, 2] + head_next_gt: Tensor | None = None, # [n_agent] + valid_next_gt: Tensor | None = None, # [n_agent] + token_agent_shape: Tensor | None = None, # [n_agent, 2] +) -> Tuple[Tensor, Tensor, Tensor]: + """ + Returns: + next_token_idx: [n_agent], without grad + next_token_traj_all: [n_agent, 6, 4, 2], local coord + sample_logits: [n_agent, n_token], without grad + """ + range_a = torch.arange(next_token_logits.shape[0]) + next_token_logits = next_token_logits.detach() + + if ( + sampling_scheme.criterium == "topk_prob" + or sampling_scheme.criterium == "topk_prob_sampled_with_dist" + ): + topk_logits, topk_indices = torch.topk( + next_token_logits, sampling_scheme.num_k, dim=-1, sorted=False + ) + if sampling_scheme.criterium == "topk_prob_sampled_with_dist": + # gt_contour: [n_agent, 4, 2] in global coord + gt_contour = cal_polygon_contour( + pos_next_gt, head_next_gt, token_agent_shape + ) + gt_contour = gt_contour.unsqueeze(1) # [n_agent, 1, 4, 2] + token_world_sample = token_traj[range_a.unsqueeze(1), topk_indices] + token_world_sample = transform_to_global( + pos_local=token_world_sample.flatten(1, 2), + head_local=None, + pos_now=pos_now, # [n_agent, 2] + head_now=head_now, # [n_agent] + )[0].view(*token_world_sample.shape) + + # dist: [n_agent, n_token] + dist = torch.norm(token_world_sample - gt_contour, dim=-1).mean(-1) + topk_logits = topk_logits.masked_fill( + valid_next_gt.unsqueeze(1), 0.0 + ) - 1.0 * dist.masked_fill(~valid_next_gt.unsqueeze(1), 0.0) + elif sampling_scheme.criterium == "topk_dist_sampled_with_prob": + # gt_contour: [n_agent, 4, 2] in global coord + gt_contour = cal_polygon_contour(pos_next_gt, head_next_gt, token_agent_shape) + gt_contour = gt_contour.unsqueeze(1) # [n_agent, 1, 4, 2] + token_world_sample = transform_to_global( + pos_local=token_traj.flatten(1, 2), # [n_agent, n_token*4, 2] + head_local=None, + pos_now=pos_now, # [n_agent, 2] + head_now=head_now, # [n_agent] + )[0].view(*token_traj.shape) + + _invalid = ~valid_next_gt + # dist: [n_agent, n_token] + dist = torch.norm(token_world_sample - gt_contour, dim=-1).mean(-1) + _logits = -1.0 * dist.masked_fill(_invalid.unsqueeze(1), 0.0) + + if _invalid.any(): + _logits[_invalid] = next_token_logits[_invalid] + _, topk_indices = torch.topk( + _logits, sampling_scheme.num_k, dim=-1, sorted=False + ) # [n_agent, K] + topk_logits = next_token_logits[range_a.unsqueeze(1), topk_indices] + + else: + raise ValueError(f"Invalid criterium: {sampling_scheme.criterium}") + + sample_logits = torch.zeros_like(next_token_logits) - float("inf") + sample_logits[range_a.unsqueeze(1), topk_indices] = topk_logits.clone() + + # topk_logits, topk_indices: [n_agent, K] + topk_logits = topk_logits / sampling_scheme.temp + samples = Categorical(logits=topk_logits).sample() # [n_agent] in K + next_token_idx = topk_indices[range_a, samples] + next_token_traj_all = token_traj_all[range_a, next_token_idx] + + return next_token_idx, next_token_traj_all, sample_logits diff --git a/src/trafficsim/alpasim_trafficsim/catk/smart/utils/weight_init.py b/src/trafficsim/alpasim_trafficsim/catk/smart/utils/weight_init.py new file mode 100644 index 00000000..84581728 --- /dev/null +++ b/src/trafficsim/alpasim_trafficsim/catk/smart/utils/weight_init.py @@ -0,0 +1,72 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation + +from torch import nn + + +def weight_init(m: nn.Module) -> None: + if isinstance(m, nn.Linear): + nn.init.xavier_uniform_(m.weight) + if m.bias is not None: + nn.init.zeros_(m.bias) + elif isinstance(m, (nn.Conv1d, nn.Conv2d, nn.Conv3d)): + fan_in = m.in_channels / m.groups + fan_out = m.out_channels / m.groups + bound = (6.0 / (fan_in + fan_out)) ** 0.5 + nn.init.uniform_(m.weight, -bound, bound) + if m.bias is not None: + nn.init.zeros_(m.bias) + elif isinstance(m, nn.Embedding): + nn.init.normal_(m.weight, mean=0.0, std=0.02) + elif isinstance(m, (nn.BatchNorm1d, nn.BatchNorm2d, nn.BatchNorm3d)): + nn.init.ones_(m.weight) + nn.init.zeros_(m.bias) + elif isinstance(m, nn.LayerNorm): + nn.init.ones_(m.weight) + nn.init.zeros_(m.bias) + elif isinstance(m, nn.MultiheadAttention): + if m.in_proj_weight is not None: + fan_in = m.embed_dim + fan_out = m.embed_dim + bound = (6.0 / (fan_in + fan_out)) ** 0.5 + nn.init.uniform_(m.in_proj_weight, -bound, bound) + else: + nn.init.xavier_uniform_(m.q_proj_weight) + nn.init.xavier_uniform_(m.k_proj_weight) + nn.init.xavier_uniform_(m.v_proj_weight) + if m.in_proj_bias is not None: + nn.init.zeros_(m.in_proj_bias) + nn.init.xavier_uniform_(m.out_proj.weight) + if m.out_proj.bias is not None: + nn.init.zeros_(m.out_proj.bias) + if m.bias_k is not None: + nn.init.normal_(m.bias_k, mean=0.0, std=0.02) + if m.bias_v is not None: + nn.init.normal_(m.bias_v, mean=0.0, std=0.02) + elif isinstance(m, (nn.LSTM, nn.LSTMCell)): + for name, param in m.named_parameters(): + if "weight_ih" in name: + for ih in param.chunk(4, 0): + nn.init.xavier_uniform_(ih) + elif "weight_hh" in name: + for hh in param.chunk(4, 0): + nn.init.orthogonal_(hh) + elif "weight_hr" in name: + nn.init.xavier_uniform_(param) + elif "bias_ih" in name: + nn.init.zeros_(param) + elif "bias_hh" in name: + nn.init.zeros_(param) + nn.init.ones_(param.chunk(4, 0)[1]) + elif isinstance(m, (nn.GRU, nn.GRUCell)): + for name, param in m.named_parameters(): + if "weight_ih" in name: + for ih in param.chunk(3, 0): + nn.init.xavier_uniform_(ih) + elif "weight_hh" in name: + for hh in param.chunk(3, 0): + nn.init.orthogonal_(hh) + elif "bias_ih" in name: + nn.init.zeros_(param) + elif "bias_hh" in name: + nn.init.zeros_(param) diff --git a/src/trafficsim/alpasim_trafficsim/config/__init__.py b/src/trafficsim/alpasim_trafficsim/config/__init__.py new file mode 100644 index 00000000..75d30d89 --- /dev/null +++ b/src/trafficsim/alpasim_trafficsim/config/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation diff --git a/src/trafficsim/alpasim_trafficsim/config/server.yaml b/src/trafficsim/alpasim_trafficsim/config/server.yaml new file mode 100644 index 00000000..249b7ee7 --- /dev/null +++ b/src/trafficsim/alpasim_trafficsim/config/server.yaml @@ -0,0 +1,50 @@ +hydra: + job: + chdir: false + +server: + host: 0.0.0.0 + port: 6200 + max_workers: 1 + log_file: null + + +catk: + device: cuda + filter_distance_th: 100.0 + predict_static: false + min_valid_history_steps: 5 + loader: + usdz_folder: ??? # set via CLI --usdz-folder or wizard volume mount + # Service timing controls. + num_history_steps: 16 + minimum_future_steps: 5 + time_step: 0.1 + + # TarCache-style map preprocessing. + map_element_names: + - lane_lines + - lane_centers + - road_boundaries + - road_islands + - crosswalks + - wait_lines + map_polyline_length_k: 4 + map_resample_interval_m: 1.0 + map_polyline_filter_mode: v_to_ego_and_obs + map_max_pts_to_ego_distance: 25.0 + map_polyline_number_control_mode: adv + map_adv_max_lane_polylines_num: 1000 + map_adv_max_road_boundary_num: 750 + map_adv_max_other_polylines_num: 250 + + # Keep runtime EnvData map un-cropped at load time. + # CATK-specific map filtering is configured below. + map_distance_x: 0.0 + map_distance_y: 0.0 + model: + config_path: /mnt/trafficsim-models/catk_v120/config.yaml + ckpt_path: /mnt/trafficsim-models/catk_v120/latest.ckpt + token_pkl_dir: /mnt/trafficsim-models/tokens + disable_sub_plyline_type: true + use_downsampled_lines: false diff --git a/src/trafficsim/alpasim_trafficsim/grpc/__init__.py b/src/trafficsim/alpasim_trafficsim/grpc/__init__.py new file mode 100644 index 00000000..165b04e2 --- /dev/null +++ b/src/trafficsim/alpasim_trafficsim/grpc/__init__.py @@ -0,0 +1,12 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation + +from alpasim_grpc.v0.common_pb2 import VersionId + +API_VERSION = (0, 54, 0) + +API_VERSION_MESSAGE = VersionId.APIVersion( + major=API_VERSION[0], + minor=API_VERSION[1], + patch=API_VERSION[2], +) diff --git a/src/trafficsim/alpasim_trafficsim/grpc/catk_predictor.py b/src/trafficsim/alpasim_trafficsim/grpc/catk_predictor.py new file mode 100644 index 00000000..75a37905 --- /dev/null +++ b/src/trafficsim/alpasim_trafficsim/grpc/catk_predictor.py @@ -0,0 +1,353 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation + +import copy +import math +from typing import Any + +import torch +from alpasim_trafficsim.grpc.config import CatkConfig +from alpasim_trafficsim.grpc.pipeline.env_builder import ( + backfill_static_agent_history, + ensure_time_axis_length, + static_agent_mask, +) +from alpasim_trafficsim.grpc.pipeline.laneline_elevation import ( + agent_center_z_from_nearest_lanelines, +) +from alpasim_trafficsim.grpc.service_structures import SessionState, SimEnvData + + +def _actions_to_env_tensors( + actions: dict[str, Any], + env_data: SimEnvData, +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + pred_xyz = ( + actions["agent_future_xyz"] + .detach() + .to( + device=env_data["agents"]["xyz"].device, + dtype=env_data["agents"]["xyz"].dtype, + ) + ) + pred_heading = ( + actions["agent_future_heading"] + .detach() + .to( + device=env_data["agents"]["heading"].device, + dtype=env_data["agents"]["heading"].dtype, + ) + ) + pred_valid = ( + actions["agent_future_valid_mask"] + .detach() + .to( + device=env_data["agents"]["valid_mask"].device, + dtype=env_data["agents"]["valid_mask"].dtype, + ) + ) + return pred_xyz, pred_heading, pred_valid + + +def _copy_unpredicted_agents_forward( + env_data: SimEnvData, + *, + total_agents: int, + num_agents: int, + step_idx: int, + prev_step_idx: int, +) -> None: + if total_agents <= num_agents: + return + env_data["agents"]["xyz"][num_agents:, step_idx, :] = env_data["agents"]["xyz"][ + num_agents:, + prev_step_idx, + :, + ] + env_data["agents"]["heading"][num_agents:, step_idx] = env_data["agents"][ + "heading" + ][num_agents:, prev_step_idx] + env_data["agents"]["valid_mask"][num_agents:, step_idx] = env_data["agents"][ + "valid_mask" + ][num_agents:, prev_step_idx] + + +def _apply_z_correction( + env_data: SimEnvData, + *, + total_agents: int, + step_idx: int, +) -> None: + if total_agents <= 0: + return + all_step_xyz = env_data["agents"]["xyz"][:total_agents, step_idx, :] + all_step_valid = env_data["agents"]["valid_mask"][:total_agents, step_idx] + all_step_xyz[:, 2] = agent_center_z_from_nearest_lanelines( + env_data.get("map"), + all_step_xyz[:, :2], + agent_lwh=env_data["agents"]["lwh"][:total_agents], + valid_mask=all_step_valid, + fallback_z=all_step_xyz[:, 2], + ) + + +def _clear_invalid_step_values( + step_xyz: torch.Tensor, + step_heading: torch.Tensor, + step_valid: torch.Tensor, +) -> None: + invalid_mask = ~step_valid + if bool(invalid_mask.any().item()): + step_xyz.masked_fill_(invalid_mask.unsqueeze(-1), 0.0) + step_heading.masked_fill_(invalid_mask, 0.0) + + +def _fill_static_agent( + processed_xyz: torch.Tensor, + processed_heading: torch.Tensor, + processed_valid: torch.Tensor, + *, + agent_idx: int, + prev_xyz: torch.Tensor, + prev_heading: torch.Tensor, + prev_valid: torch.Tensor, +) -> None: + processed_xyz[agent_idx, :, :] = prev_xyz + processed_heading[agent_idx, :] = prev_heading + processed_valid[agent_idx, :] = prev_valid + + +def _carry_invalid_predictions_forward( + processed_xyz: torch.Tensor, + processed_heading: torch.Tensor, + processed_valid: torch.Tensor, + *, + agent_idx: int, + prev_xyz: torch.Tensor, + prev_heading: torch.Tensor, + prev_valid: torch.Tensor, +) -> None: + last_xyz = prev_xyz + last_heading = prev_heading + last_valid = bool(prev_valid.item()) + for step_offset in range(processed_xyz.shape[1]): + if bool(processed_valid[agent_idx, step_offset].item()): + last_xyz = processed_xyz[agent_idx, step_offset, :] + last_heading = processed_heading[agent_idx, step_offset] + last_valid = True + continue + if not last_valid: + continue + processed_xyz[agent_idx, step_offset, :] = last_xyz + processed_heading[agent_idx, step_offset] = last_heading + processed_valid[agent_idx, step_offset] = True + + +def _clone_env_data_for_model(env_data: SimEnvData) -> SimEnvData: + model_env_data = dict(env_data) + model_env_data["map"] = copy.deepcopy(env_data.get("map", {})) + model_env_data["agents"] = dict(env_data["agents"]) + for key in ("xyz", "heading", "valid_mask"): + model_env_data["agents"][key] = env_data["agents"][key].clone() + return model_env_data + + +def _write_predictions_to_env( + env_data: SimEnvData, + *, + future_step_indices: list[int], + total_agents: int, + num_agents: int, + processed_xyz: torch.Tensor, + processed_heading: torch.Tensor, + processed_valid: torch.Tensor, +) -> None: + for step_offset, step_idx in enumerate(future_step_indices): + prev_step_idx = max(step_idx - 1, 0) + _copy_unpredicted_agents_forward( + env_data, + total_agents=total_agents, + num_agents=num_agents, + step_idx=step_idx, + prev_step_idx=prev_step_idx, + ) + step_xyz = env_data["agents"]["xyz"][:num_agents, step_idx, :] + step_heading = env_data["agents"]["heading"][:num_agents, step_idx] + step_valid = env_data["agents"]["valid_mask"][:num_agents, step_idx] + + step_xyz[:] = processed_xyz[:, step_offset, :] + step_heading[:] = processed_heading[:, step_offset] + step_valid[:] = processed_valid[:, step_offset] + _apply_z_correction( + env_data, + total_agents=total_agents, + step_idx=step_idx, + ) + _clear_invalid_step_values(step_xyz, step_heading, step_valid) + + +class CATKTrafficPredictor: + def __init__(self, catk_cfg: CatkConfig) -> None: + self.cfg = catk_cfg + self.predict_static = self.cfg.predict_static + self.history_window_steps = self.cfg.loader.num_history_steps + self.min_valid_history_steps = self.cfg.min_valid_history_steps + self.model = self._build_model() + self._token_stride: int = self.model.model.encoder.agent_encoder.shift + + def _build_model(self) -> Any: + from alpasim_trafficsim.catk.model_adapter import CATK + + model_cfg = self.cfg.model + return CATK( + config_path=model_cfg.config_path, + ckpt_path=model_cfg.ckpt_path, + token_pkl_dir=model_cfg.token_pkl_dir, + disable_sub_plyline_type=model_cfg.disable_sub_plyline_type, + use_downsampled_lines=model_cfg.use_downsampled_lines, + device=self.cfg.device, + ) + + def run_inference( + self, + env_data: SimEnvData, + *, + predict_steps: int, + ) -> dict[str, Any] | None: + if self.model is None or predict_steps <= 0: + return None + # CATK emits full token strides; the caller crops to requested steps. + model_steps = math.ceil(predict_steps / self._token_stride) * self._token_stride + self.model.model_predict_step_num = model_steps + model_env_data = _clone_env_data_for_model(env_data) + backfill_static_agent_history( + model_env_data, + curr_t=int(model_env_data["env"].get("curr_t", 0)), + history_window_steps=self.history_window_steps, + predict_static=self.predict_static, + ) + model_input_result = self.model.create_model_input( + model_env_data, + filter_map_by_ego=True, + filter_distance_th=self.cfg.filter_distance_th, + ) + if model_input_result is None: + return None + return self.model.inference(model_input_result["input_data"]) + + def apply_predictions_to_env( + self, + session_state: SessionState, + *, + future_step_indices: list[int], + actions: dict[str, Any], + ) -> list[int]: + assert session_state.env_data is not None + env_data = session_state.env_data + if not future_step_indices: + return [] + + pred_xyz, pred_heading, pred_valid = _actions_to_env_tensors(actions, env_data) + total_agents = env_data["agents"]["xyz"].shape[0] + num_agents = min(total_agents, pred_xyz.shape[0]) + num_steps = min(len(future_step_indices), pred_xyz.shape[1]) + active_future_step_indices = future_step_indices[:num_steps] + for step_idx in active_future_step_indices: + ensure_time_axis_length(env_data, step_idx) + if not active_future_step_indices: + return [] + + processed_xyz, processed_heading, processed_valid = ( + self._postprocess_predictions( + env_data, + future_step_indices=active_future_step_indices, + pred_xyz=pred_xyz[:num_agents, :num_steps, :], + pred_heading=pred_heading[:num_agents, :num_steps], + pred_valid=pred_valid[:num_agents, :num_steps], + ) + ) + _write_predictions_to_env( + env_data, + future_step_indices=active_future_step_indices, + total_agents=total_agents, + num_agents=num_agents, + processed_xyz=processed_xyz, + processed_heading=processed_heading, + processed_valid=processed_valid, + ) + return active_future_step_indices + + def _postprocess_predictions( + self, + env_data: SimEnvData, + *, + future_step_indices: list[int], + pred_xyz: torch.Tensor, + pred_heading: torch.Tensor, + pred_valid: torch.Tensor, + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + processed_xyz = pred_xyz.clone() + processed_heading = pred_heading.clone() + processed_valid = pred_valid.bool().clone() + num_agents = int(processed_xyz.shape[0]) + num_steps = int(processed_xyz.shape[1]) + if num_agents == 0 or num_steps == 0 or not future_step_indices: + return processed_xyz, processed_heading, processed_valid + + prev_step_idx = max(int(future_step_indices[0]) - 1, 0) + prev_xyz = env_data["agents"]["xyz"][:num_agents, prev_step_idx, :] + prev_heading = env_data["agents"]["heading"][:num_agents, prev_step_idx] + prev_valid = env_data["agents"]["valid_mask"][:num_agents, prev_step_idx] + history_beg = max(prev_step_idx - self.history_window_steps + 1, 0) + history_valid_count = env_data["agents"]["valid_mask"][ + :num_agents, history_beg : prev_step_idx + 1 + ].sum(dim=1) + sparse_history_mask = history_valid_count < self.min_valid_history_steps + frozen_static_mask = ( + static_agent_mask(env_data, device=processed_valid.device)[:num_agents] + if not self.predict_static + else torch.zeros( + (num_agents,), + dtype=torch.bool, + device=processed_valid.device, + ) + ) + + for agent_idx in range(num_agents): + if bool(frozen_static_mask[agent_idx].item()): + _fill_static_agent( + processed_xyz, + processed_heading, + processed_valid, + agent_idx=agent_idx, + prev_xyz=prev_xyz[agent_idx], + prev_heading=prev_heading[agent_idx], + prev_valid=prev_valid[agent_idx], + ) + continue + + if bool(sparse_history_mask[agent_idx].item()) and bool( + prev_valid[agent_idx].item() + ): + _fill_static_agent( + processed_xyz, + processed_heading, + processed_valid, + agent_idx=agent_idx, + prev_xyz=prev_xyz[agent_idx], + prev_heading=prev_heading[agent_idx], + prev_valid=prev_valid[agent_idx], + ) + continue + + _carry_invalid_predictions_forward( + processed_xyz, + processed_heading, + processed_valid, + agent_idx=agent_idx, + prev_xyz=prev_xyz[agent_idx], + prev_heading=prev_heading[agent_idx], + prev_valid=prev_valid[agent_idx], + ) + + return processed_xyz, processed_heading, processed_valid diff --git a/src/trafficsim/alpasim_trafficsim/grpc/catk_trafficsim.py b/src/trafficsim/alpasim_trafficsim/grpc/catk_trafficsim.py new file mode 100644 index 00000000..23aaecc7 --- /dev/null +++ b/src/trafficsim/alpasim_trafficsim/grpc/catk_trafficsim.py @@ -0,0 +1,67 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation + +from concurrent import futures +from pathlib import Path + +import hydra +from alpasim_grpc.v0 import traffic_pb2_grpc +from alpasim_trafficsim.grpc.config import ( + TrafficServerConfig, + resolve_traffic_server_config, +) +from alpasim_trafficsim.grpc.servicer import TrafficServiceServicer +from loguru import logger +from omegaconf import DictConfig + +import grpc + + +def _validate_usdz_folder(cfg: TrafficServerConfig) -> Path: + usdz_folder = Path(cfg.catk.loader.usdz_folder) + if not usdz_folder.is_dir(): + raise FileNotFoundError( + f"catk.loader.usdz_folder does not exist or is not a directory: {usdz_folder}" + ) + if not any(usdz_folder.rglob("*.usdz")): + raise FileNotFoundError( + f"catk.loader.usdz_folder contains no .usdz files recursively: {usdz_folder}" + ) + return usdz_folder + + +def serve(cfg: TrafficServerConfig) -> grpc.Server: + usdz_folder = _validate_usdz_folder(cfg) + server = grpc.server(futures.ThreadPoolExecutor(max_workers=cfg.server.max_workers)) + servicer = TrafficServiceServicer( + server, + usdz_folder=usdz_folder, + catk_config=cfg.catk, + ) + traffic_pb2_grpc.add_TrafficServiceServicer_to_server(servicer, server) + server.add_insecure_port(f"{cfg.server.host}:{cfg.server.port}") + logger.info( + "Starting CATK traffic gRPC server on {}:{}", + cfg.server.host, + cfg.server.port, + ) + server.start() + server.wait_for_termination() + return server + + +@hydra.main(version_base=None, config_path="../config", config_name="server") +def main(hydra_cfg: DictConfig) -> None: + cfg = resolve_traffic_server_config(hydra_cfg) + + log_file_str = cfg.server.log_file + if log_file_str is not None: + log_path = Path(log_file_str) + log_path.parent.mkdir(parents=True, exist_ok=True) + logger.add(log_path, enqueue=True) + + serve(cfg) + + +if __name__ == "__main__": + main() diff --git a/src/trafficsim/alpasim_trafficsim/grpc/config.py b/src/trafficsim/alpasim_trafficsim/grpc/config.py new file mode 100644 index 00000000..9caeb2be --- /dev/null +++ b/src/trafficsim/alpasim_trafficsim/grpc/config.py @@ -0,0 +1,86 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation + +"""Typed configuration for the CATK traffic gRPC service.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, cast + +from omegaconf import MISSING, OmegaConf + + +def _default_map_element_names() -> list[str]: + return [ + "lane_lines", + "lane_centers", + "road_boundaries", + "road_islands", + "crosswalks", + "wait_lines", + ] + + +@dataclass +class ServerConfig: + host: str = "0.0.0.0" + port: int = 6200 + max_workers: int = 1 + log_file: str | None = None + + +@dataclass +class CatkLoaderConfig: + usdz_folder: str = MISSING + num_history_steps: int = 16 + minimum_future_steps: int = 5 + time_step: float = 0.1 + + map_element_names: list[str] | None = field( + default_factory=_default_map_element_names + ) + map_polyline_length_k: int = 4 + map_resample_interval_m: float | None = 1.0 + map_polyline_filter_mode: str = "v_to_ego_and_obs" + map_max_pts_to_ego_distance: float = 25.0 + map_polyline_number_control_mode: str = "adv" + map_adv_max_lane_polylines_num: int = 1000 + map_adv_max_road_boundary_num: int = 750 + map_adv_max_other_polylines_num: int = 250 + + map_distance_x: float = 0.0 + map_distance_y: float = 0.0 + + +@dataclass +class CatkModelConfig: + config_path: str = MISSING + ckpt_path: str = MISSING + token_pkl_dir: str = MISSING + disable_sub_plyline_type: bool = True + use_downsampled_lines: bool = False + + +@dataclass +class CatkConfig: + device: str = "cuda" + filter_distance_th: float = 100.0 + predict_static: bool = False + min_valid_history_steps: int = 5 + loader: CatkLoaderConfig = field(default_factory=CatkLoaderConfig) + model: CatkModelConfig = field(default_factory=CatkModelConfig) + + +@dataclass +class TrafficServerConfig: + server: ServerConfig = field(default_factory=ServerConfig) + catk: CatkConfig = field(default_factory=CatkConfig) + + +def resolve_traffic_server_config(cfg: Any) -> TrafficServerConfig: + """Merge a Hydra/OmegaConf config with the typed traffic service schema.""" + schema = OmegaConf.structured(TrafficServerConfig) + merged = OmegaConf.merge(schema, cfg) + OmegaConf.resolve(merged) + return cast(TrafficServerConfig, OmegaConf.to_object(merged)) diff --git a/src/trafficsim/alpasim_trafficsim/grpc/pipeline/__init__.py b/src/trafficsim/alpasim_trafficsim/grpc/pipeline/__init__.py new file mode 100644 index 00000000..e37f46e6 --- /dev/null +++ b/src/trafficsim/alpasim_trafficsim/grpc/pipeline/__init__.py @@ -0,0 +1,19 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation + +"""Traffic gRPC pipeline helpers.""" + +from .env_builder import ( + build_session_env_data, + populate_ego_future_from_trajectory, + snapshot_dynamic_env_data, +) +from .response_builder import build_agent_updates_from_env, build_simulation_response + +__all__ = [ + "build_agent_updates_from_env", + "build_session_env_data", + "build_simulation_response", + "populate_ego_future_from_trajectory", + "snapshot_dynamic_env_data", +] diff --git a/src/trafficsim/alpasim_trafficsim/grpc/pipeline/env_builder.py b/src/trafficsim/alpasim_trafficsim/grpc/pipeline/env_builder.py new file mode 100644 index 00000000..eff2c58f --- /dev/null +++ b/src/trafficsim/alpasim_trafficsim/grpc/pipeline/env_builder.py @@ -0,0 +1,557 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation + +import copy + +import numpy as np +import torch +from alpasim_grpc.v0 import common_pb2, traffic_pb2 +from alpasim_trafficsim.grpc.service_structures import SimEnvData +from alpasim_utils.geometry import ( + Pose, + Trajectory, + pose_from_grpc, + pose_to_grpc_at_time, + quat_to_yaw, + yaw_to_quat_components, +) +from loguru import logger + + +class InsufficientEgoTrajectoryError(ValueError): + """Raised when ego conditioning does not cover required CATK samples.""" + + +def sample_start_timestamp_us(env_data: SimEnvData) -> int: + sample_start_t_us = env_data["env"].get("sample_start_t_us") + if sample_start_t_us is not None: + return int(sample_start_t_us) + + metadata = env_data.get("metadata", {}) + if "t0_us" in metadata: + return int(metadata["t0_us"]) + + raise KeyError("env_data is missing both env.sample_start_t_us and metadata.t0_us") + + +def step_idx_to_timestamp_us(env_data: SimEnvData, step_idx: int, *, dt_us: int) -> int: + return sample_start_timestamp_us(env_data) + step_idx * dt_us + + +def snapshot_dynamic_env_data(env_data: SimEnvData) -> SimEnvData: + """Return a shallow snapshot of the dynamic ``env_data`` sub-dicts. + + The ``env``/``ego``/``agents`` mappings are copied into new dicts so the + caller can re-key them without mutating the source, while the tensors and the + ``map``/``metadata`` containers are shared by reference (no deep copy). + """ + return { + "metadata": env_data.get("metadata", {}), + "env": dict(env_data.get("env", {})), + "ego": dict(env_data["ego"]), + "agents": dict(env_data["agents"]), + "map": env_data.get("map", {}), + } + + +def ensure_time_axis_length(env_data: SimEnvData, required_step_idx: int) -> None: + required_steps = required_step_idx + 1 + current_steps = env_data["ego"]["xyz"].shape[0] + if required_steps <= current_steps: + return + + pad_steps = required_steps - current_steps + ego_device = env_data["ego"]["xyz"].device + agent_device = env_data["agents"]["xyz"].device + num_agents = env_data["agents"]["xyz"].shape[0] + + env_data["ego"]["xyz"] = torch.cat( + [ + env_data["ego"]["xyz"], + torch.zeros((pad_steps, 3), dtype=torch.float32, device=ego_device), + ], + dim=0, + ) + env_data["ego"]["heading"] = torch.cat( + [ + env_data["ego"]["heading"], + torch.zeros((pad_steps,), dtype=torch.float32, device=ego_device), + ], + dim=0, + ) + env_data["agents"]["xyz"] = torch.cat( + [ + env_data["agents"]["xyz"], + torch.zeros( + (num_agents, pad_steps, 3), dtype=torch.float32, device=agent_device + ), + ], + dim=1, + ) + env_data["agents"]["heading"] = torch.cat( + [ + env_data["agents"]["heading"], + torch.zeros( + (num_agents, pad_steps), dtype=torch.float32, device=agent_device + ), + ], + dim=1, + ) + env_data["agents"]["valid_mask"] = torch.cat( + [ + env_data["agents"]["valid_mask"], + torch.zeros((num_agents, pad_steps), dtype=torch.bool, device=agent_device), + ], + dim=1, + ) + + +def reset_env_dynamic_state( + env_data: SimEnvData, + *, + total_steps: int, + curr_t: int, + sample_start_t_us: int, + dt_us: int, +) -> None: + total_steps = max(total_steps, 1) + ego_device = env_data["ego"]["xyz"].device + agent_device = env_data["agents"]["xyz"].device + num_agents = env_data["agents"]["xyz"].shape[0] + + env_data["ego"]["xyz"] = torch.zeros( + (total_steps, 3), + dtype=torch.float32, + device=ego_device, + ) + env_data["ego"]["heading"] = torch.zeros( + (total_steps,), + dtype=torch.float32, + device=ego_device, + ) + env_data["agents"]["xyz"] = torch.zeros( + (num_agents, total_steps, 3), + dtype=torch.float32, + device=agent_device, + ) + env_data["agents"]["heading"] = torch.zeros( + (num_agents, total_steps), + dtype=torch.float32, + device=agent_device, + ) + env_data["agents"]["valid_mask"] = torch.zeros( + (num_agents, total_steps), + dtype=torch.bool, + device=agent_device, + ) + env_data["env"]["curr_t"] = max(curr_t, 0) + env_data["env"]["sample_start_t_us"] = sample_start_t_us + env_data["current_time_us"] = torch.tensor( + [[sample_start_t_us + max(curr_t, 0) * dt_us]], + dtype=torch.long, + ) + + +def parse_object_id(object_id: str, fallback_idx: int) -> int: + try: + return int(object_id) + except (TypeError, ValueError): + return fallback_idx + + +def agent_object_id_to_index(env_data: SimEnvData) -> dict[str, int]: + agent_object_ids = env_data["env"].get("agent_object_ids") + if agent_object_ids is not None: + return {str(object_id): idx for idx, object_id in enumerate(agent_object_ids)} + track_ids = env_data["agents"]["track_ids"] + return {str(int(track_id)): idx for idx, track_id in enumerate(track_ids.tolist())} + + +def build_session_env_data( + *, + base_env_data: SimEnvData, + logged_object_trajectories: list[traffic_pb2.ObjectTrajectory], +) -> SimEnvData: + env_data = copy.deepcopy(base_env_data) + agent_xyz_template = env_data["agents"]["xyz"] + agent_heading_template = env_data["agents"]["heading"] + agent_valid_template = env_data["agents"]["valid_mask"] + agent_lwh_template = env_data["agents"]["lwh"] + agent_steps = max(agent_xyz_template.shape[1], 1) + + loader_track_ids = torch.as_tensor( + env_data["agents"]["track_ids"], + dtype=torch.long, + device=agent_xyz_template.device, + ).detach() + loader_class_ids = torch.as_tensor( + env_data["agents"]["class_ids"], + dtype=torch.long, + device=agent_xyz_template.device, + ).detach() + class_id_by_track_id = { + int(track_id): int(class_id) + for track_id, class_id in zip( + loader_track_ids.detach().cpu().tolist(), + loader_class_ids.detach().cpu().tolist(), + strict=False, + ) + } + lwh_by_track_id = { + int(track_id): agent_lwh_template[idx].clone() + for idx, track_id in enumerate(loader_track_ids.detach().cpu().tolist()) + } + + obstacle_class_name_to_id = env_data["metadata"].get("obstacle_class_name_2_id", {}) + default_class_id = int( + obstacle_class_name_to_id.get("car", obstacle_class_name_to_id.get("others", 0)) + ) + + ego_object = next( + ( + logged_object + for logged_object in logged_object_trajectories + if str(logged_object.object_id).upper() == "EGO" + ), + None, + ) + if ego_object is not None: + env_data["ego"]["lwh"] = agent_lwh_template.new_tensor( + [ + float(ego_object.aabb.size_x), + float(ego_object.aabb.size_y), + float(ego_object.aabb.size_z), + ], + dtype=torch.float32, + ) + + agent_objects = [ + logged_object + for logged_object in logged_object_trajectories + if str(logged_object.object_id).upper() != "EGO" + ] + num_agents = len(agent_objects) + agent_xyz = torch.zeros( + (num_agents, agent_steps, 3), + dtype=agent_xyz_template.dtype, + device=agent_xyz_template.device, + ) + agent_heading = torch.zeros( + (num_agents, agent_steps), + dtype=agent_heading_template.dtype, + device=agent_heading_template.device, + ) + agent_valid_mask = torch.zeros( + (num_agents, agent_steps), + dtype=agent_valid_template.dtype, + device=agent_valid_template.device, + ) + agent_lwh = torch.zeros( + (num_agents, 3), + dtype=agent_lwh_template.dtype, + device=agent_lwh_template.device, + ) + agent_track_ids: list[int] = [] + agent_class_ids: list[int] = [] + agent_object_ids: list[str] = [] + agent_is_static: list[bool] = [] + + for agent_idx, logged_object in enumerate(agent_objects): + object_id = str(logged_object.object_id) + track_id = parse_object_id(object_id, agent_idx + 1) + agent_track_ids.append(track_id) + agent_object_ids.append(object_id) + agent_is_static.append(bool(logged_object.is_static)) + agent_class_ids.append(class_id_by_track_id.get(track_id, default_class_id)) + + if logged_object.HasField("aabb"): + agent_lwh[agent_idx] = agent_lwh_template.new_tensor( + [ + float(logged_object.aabb.size_x), + float(logged_object.aabb.size_y), + float(logged_object.aabb.size_z), + ], + dtype=agent_lwh_template.dtype, + ) + elif track_id in lwh_by_track_id: + agent_lwh[agent_idx] = lwh_by_track_id[track_id].to( + device=agent_lwh_template.device, + dtype=agent_lwh_template.dtype, + ) + + agent_track_ids_tensor = torch.tensor( + agent_track_ids, + dtype=torch.long, + device=agent_xyz_template.device, + ) + agent_class_ids_tensor = torch.tensor( + agent_class_ids, + dtype=torch.long, + device=agent_xyz_template.device, + ) + env_data["agents"] = { + "xyz": agent_xyz, + "heading": agent_heading, + "valid_mask": agent_valid_mask, + "lwh": agent_lwh, + "track_ids": agent_track_ids_tensor, + "class_ids": agent_class_ids_tensor, + "num_obstacles": num_agents, + } + env_data.setdefault("env", {}) + env_data["env"]["agent_object_ids"] = agent_object_ids + env_data["env"]["agent_is_static"] = agent_is_static + return env_data + + +def write_pose_to_env( + env_data: SimEnvData, + *, + object_id: str, + step_idx: int, + pose_at_time: common_pb2.PoseAtTime, + agent_object_id_to_idx: dict[str, int] | None = None, +) -> None: + ensure_time_axis_length(env_data, step_idx) + if str(object_id).upper() == "EGO": + env_data["ego"]["xyz"][step_idx, 0] = float(pose_at_time.pose.vec.x) + env_data["ego"]["xyz"][step_idx, 1] = float(pose_at_time.pose.vec.y) + env_data["ego"]["xyz"][step_idx, 2] = float(pose_at_time.pose.vec.z) + env_data["ego"]["heading"][step_idx] = quat_to_yaw(pose_at_time.pose.quat) + return + + object_id_to_idx = ( + agent_object_id_to_idx + if agent_object_id_to_idx is not None + else agent_object_id_to_index(env_data) + ) + agent_idx = object_id_to_idx.get(str(object_id)) + + if agent_idx is None: + return + + env_data["agents"]["xyz"][agent_idx, step_idx, 0] = float(pose_at_time.pose.vec.x) + env_data["agents"]["xyz"][agent_idx, step_idx, 1] = float(pose_at_time.pose.vec.y) + env_data["agents"]["xyz"][agent_idx, step_idx, 2] = float(pose_at_time.pose.vec.z) + env_data["agents"]["heading"][agent_idx, step_idx] = quat_to_yaw( + pose_at_time.pose.quat + ) + env_data["agents"]["valid_mask"][agent_idx, step_idx] = True + + +def agent_is_static_by_object_id(env_data: SimEnvData) -> dict[str, bool]: + agent_object_ids = env_data["env"].get("agent_object_ids") or [] + agent_is_static = env_data["env"].get("agent_is_static") or [] + return { + str(object_id): ( + bool(agent_is_static[idx]) if idx < len(agent_is_static) else False + ) + for idx, object_id in enumerate(agent_object_ids) + } + + +def static_agent_mask(env_data: SimEnvData, *, device: torch.device) -> torch.Tensor: + num_agents = int(env_data["agents"]["xyz"].shape[0]) + raw_mask = env_data["env"].get("agent_is_static") + if raw_mask is None: + return torch.zeros((num_agents,), dtype=torch.bool, device=device) + mask = torch.as_tensor(raw_mask, dtype=torch.bool, device=device).flatten() + if int(mask.numel()) >= num_agents: + return mask[:num_agents] + padded = torch.zeros((num_agents,), dtype=torch.bool, device=device) + padded[: int(mask.numel())] = mask + return padded + + +def backfill_static_agent_history( + env_data: SimEnvData, + *, + curr_t: int, + history_window_steps: int, + predict_static: bool, +) -> None: + """Fill static-agent history with the current pose for model input.""" + agents = env_data["agents"] + total_agents = int(agents["xyz"].shape[0]) + num_agents = min(int(agents["num_obstacles"]), total_agents) + if num_agents <= 0 or curr_t < 0: + return + + curr_t = min(curr_t, int(agents["xyz"].shape[1]) - 1) + history_beg = max(curr_t - history_window_steps + 1, 0) + history_slice = slice(history_beg, curr_t + 1) + prev_valid = agents["valid_mask"][:num_agents, curr_t] + frozen_static_mask = ( + static_agent_mask(env_data, device=prev_valid.device)[:num_agents] + if not predict_static + else torch.zeros((num_agents,), dtype=torch.bool, device=prev_valid.device) + ) + backfill_mask = frozen_static_mask & prev_valid + if not bool(backfill_mask.any().item()): + return + + agent_indices = torch.where(backfill_mask)[0] + history_len = curr_t - history_beg + 1 + agents["xyz"][agent_indices, history_slice, :] = ( + agents["xyz"][agent_indices, curr_t, :].unsqueeze(1).expand(-1, history_len, -1) + ) + agents["heading"][agent_indices, history_slice] = ( + agents["heading"][agent_indices, curr_t].unsqueeze(1).expand(-1, history_len) + ) + agents["valid_mask"][agent_indices, history_slice] = True + + +def first_trajectory_pose_at_timestamp( + trajectory: Trajectory, + *, + timestamp_us: int, +) -> common_pb2.PoseAtTime | None: + if trajectory.is_empty(): + return None + return pose_to_grpc_at_time(trajectory.first_pose, int(timestamp_us)) + + +def populate_ego_future_from_trajectory( + env_data: SimEnvData, + ego_trajectory: Trajectory, + *, + current_step_idx: int, + requested_timestamp_us: int, + future_step_indices: list[int], + dt_us: int, +) -> None: + if not future_step_indices: + return + + anchor_timestamp_us = step_idx_to_timestamp_us( + env_data, + current_step_idx, + dt_us=dt_us, + ) + anchor_heading = float(env_data["ego"]["heading"][current_step_idx].item()) + anchor_quat_w, anchor_quat_x, anchor_quat_y, anchor_quat_z = yaw_to_quat_components( + anchor_heading + ) + anchor_pose = Pose.from_denormalized_quat( + env_data["ego"]["xyz"][current_step_idx, :] + .detach() + .cpu() + .numpy() + .astype(np.float32, copy=False), + np.asarray( + [anchor_quat_x, anchor_quat_y, anchor_quat_z, anchor_quat_w], + dtype=np.float32, + ), + ) + try: + requested_pose = pose_to_grpc_at_time( + ego_trajectory.interpolate_pose(int(requested_timestamp_us)), + int(requested_timestamp_us), + ) + except ValueError: + requested_pose = None + future_start_ts_us = step_idx_to_timestamp_us( + env_data, + future_step_indices[0], + dt_us=dt_us, + ) + future_end_ts_us = step_idx_to_timestamp_us( + env_data, + future_step_indices[-1], + dt_us=dt_us, + ) + if requested_pose is None: + logger.debug( + "catk_ego_conditioning: mode=sample_stream anchor_ts_us={} " + "requested_ts_us={} future_steps={} future_start_ts_us={} " + "future_end_ts_us={}", + anchor_timestamp_us, + requested_timestamp_us, + len(future_step_indices), + future_start_ts_us, + future_end_ts_us, + ) + for step_idx in future_step_indices: + timestamp_us = step_idx_to_timestamp_us( + env_data, + step_idx, + dt_us=dt_us, + ) + try: + sampled_pose = pose_to_grpc_at_time( + ego_trajectory.interpolate_pose(int(timestamp_us)), + int(timestamp_us), + ) + except ValueError as exc: + raise InsufficientEgoTrajectoryError( + "EGO trajectory does not cover required simulation timestamp " + f"{timestamp_us}; request timestamp is {requested_timestamp_us} " + f"and future sample end is {future_end_ts_us}" + ) from exc + write_pose_to_env( + env_data, + object_id="EGO", + step_idx=step_idx, + pose_at_time=sampled_pose, + ) + return + + interpolation_trajectory = Trajectory.from_poses( + np.asarray( + [anchor_timestamp_us, requested_pose.timestamp_us], + dtype=np.uint64, + ), + [ + anchor_pose, + pose_from_grpc(requested_pose.pose), + ], + ) + logger.debug( + "catk_ego_conditioning: mode=interpolate_single_endpoint " + "anchor_ts_us={} requested_ts_us={} future_steps={} " + "future_start_ts_us={} future_end_ts_us={}", + anchor_timestamp_us, + requested_timestamp_us, + len(future_step_indices), + future_start_ts_us, + future_end_ts_us, + ) + for step_idx in future_step_indices: + interpolation_timestamp_us = step_idx_to_timestamp_us( + env_data, + step_idx, + dt_us=dt_us, + ) + if interpolation_timestamp_us <= requested_timestamp_us: + try: + sampled_pose = pose_to_grpc_at_time( + interpolation_trajectory.interpolate_pose( + int(interpolation_timestamp_us) + ), + int(interpolation_timestamp_us), + ) + except ValueError as exc: + raise InsufficientEgoTrajectoryError( + "EGO trajectory does not cover required simulation " + f"timestamp {interpolation_timestamp_us}; request timestamp is " + f"{requested_timestamp_us} and future sample end is " + f"{future_end_ts_us}" + ) from exc + else: + try: + sampled_pose = pose_to_grpc_at_time( + ego_trajectory.interpolate_pose(int(interpolation_timestamp_us)), + int(interpolation_timestamp_us), + ) + except ValueError: + sampled_pose = common_pb2.PoseAtTime( + timestamp_us=int(interpolation_timestamp_us), + pose=requested_pose.pose, + ) + + write_pose_to_env( + env_data, + object_id="EGO", + step_idx=step_idx, + pose_at_time=sampled_pose, + ) diff --git a/src/trafficsim/alpasim_trafficsim/grpc/pipeline/laneline_elevation.py b/src/trafficsim/alpasim_trafficsim/grpc/pipeline/laneline_elevation.py new file mode 100644 index 00000000..386c4cb2 --- /dev/null +++ b/src/trafficsim/alpasim_trafficsim/grpc/pipeline/laneline_elevation.py @@ -0,0 +1,178 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation + +from __future__ import annotations + +from typing import Any + +import torch + +LANELINE_MAP_KEYS = ("lanelines", "lane_lines") + + +def agent_center_z_from_nearest_lanelines( + map_data: dict[str, Any] | None, + agent_xy: torch.Tensor, + *, + agent_lwh: torch.Tensor | None = None, + valid_mask: torch.Tensor | None = None, + fallback_z: torch.Tensor | float | None = None, +) -> torch.Tensor: + """Return agent center z from nearest laneline z plus half the agent height.""" + if agent_xy.shape[-1] < 2: + raise ValueError(f"agent_xy must have trailing xy dims, got {agent_xy.shape}") + + target_shape = agent_xy.shape[:-1] + if fallback_z is None: + out_z = agent_xy.new_zeros(target_shape) + elif torch.is_tensor(fallback_z): + out_z = fallback_z.to(device=agent_xy.device, dtype=agent_xy.dtype).clone() + if out_z.shape != target_shape: + out_z = out_z.expand(target_shape).clone() + else: + out_z = agent_xy.new_full(target_shape, float(fallback_z)) + + segments = _laneline_segments_from_map( + map_data, + device=agent_xy.device, + dtype=agent_xy.dtype, + ) + if segments is None: + return out_z + + flat_xy = agent_xy[..., :2].reshape(-1, 2) + flat_out_z = out_z.reshape(-1) + query_mask = torch.isfinite(flat_xy).all(dim=-1) + if valid_mask is not None: + query_mask = query_mask & valid_mask.to( + device=agent_xy.device, + dtype=torch.bool, + ).reshape(-1) + if not bool(query_mask.any().item()): + return out_z + + flat_out_z[query_mask] = ( + _nearest_segment_z( + flat_xy[query_mask], + segments, + ) + + _agent_half_height( + agent_lwh, + target_shape=target_shape, + device=agent_xy.device, + dtype=agent_xy.dtype, + ).reshape(-1)[query_mask] + ) + return out_z + + +def _agent_half_height( + agent_lwh: torch.Tensor | None, + *, + target_shape: torch.Size, + device: torch.device, + dtype: torch.dtype, +) -> torch.Tensor: + if agent_lwh is None: + return torch.zeros(target_shape, device=device, dtype=dtype) + half_height = agent_lwh.to(device=device, dtype=dtype)[..., 2] * 0.5 + while half_height.ndim < len(target_shape): + half_height = half_height.unsqueeze(-1) + return half_height.expand(target_shape) + + +def _laneline_segments_from_map( + map_data: dict[str, Any] | None, + *, + device: torch.device, + dtype: torch.dtype, +) -> torch.Tensor | None: + if not map_data: + return None + + segment_chunks: list[torch.Tensor] = [] + for map_key in LANELINE_MAP_KEYS: + map_layer = map_data.get(map_key) + if not isinstance(map_layer, dict): + continue + polylines = map_layer.get("polylines") + if polylines is None: + continue + segment_chunks.extend( + _segments_from_polylines(polylines, device=device, dtype=dtype) + ) + + if not segment_chunks: + return None + return torch.cat(segment_chunks, dim=0) + + +def _segments_from_polylines( + polylines: Any, + *, + device: torch.device, + dtype: torch.dtype, +) -> list[torch.Tensor]: + if isinstance(polylines, (list, tuple)): + chunks: list[torch.Tensor] = [] + for polyline in polylines: + chunks.extend( + _segments_from_polylines(polyline, device=device, dtype=dtype) + ) + return chunks + + polyline_tensor = torch.as_tensor(polylines, device=device, dtype=dtype) + if polyline_tensor.ndim == 2: + polyline_tensor = polyline_tensor.unsqueeze(0) + if polyline_tensor.ndim != 3 or polyline_tensor.shape[-1] < 3: + return [] + if polyline_tensor.shape[1] < 2: + return [] + + start = polyline_tensor[:, :-1, :3].reshape(-1, 3) + end = polyline_tensor[:, 1:, :3].reshape(-1, 3) + segment_xy = end[:, :2] - start[:, :2] + valid = ( + torch.isfinite(start).all(dim=-1) + & torch.isfinite(end).all(dim=-1) + & (segment_xy.square().sum(dim=-1) > 1e-12) + ) + if not bool(valid.any().item()): + return [] + return [torch.stack((start[valid], end[valid]), dim=1)] + + +def _nearest_segment_z( + query_xy: torch.Tensor, + segments: torch.Tensor, + *, + chunk_size: int = 2048, +) -> torch.Tensor: + segment_start_xy = segments[:, 0, :2] + segment_delta_xy = segments[:, 1, :2] - segment_start_xy + segment_start_z = segments[:, 0, 2] + segment_delta_z = segments[:, 1, 2] - segment_start_z + segment_len_sq = segment_delta_xy.square().sum(dim=-1).clamp_min(1e-12) + + nearest_z = query_xy.new_empty((query_xy.shape[0],)) + for chunk_start in range(0, int(query_xy.shape[0]), int(chunk_size)): + chunk_stop = min(chunk_start + int(chunk_size), int(query_xy.shape[0])) + chunk_xy = query_xy[chunk_start:chunk_stop] + rel_xy = chunk_xy[:, None, :] - segment_start_xy[None, :, :] + t = ( + (rel_xy * segment_delta_xy[None, :, :]).sum(dim=-1) + / segment_len_sq[None, :] + ).clamp(0.0, 1.0) + closest_xy = segment_start_xy[None, :, :] + ( + t[..., None] * segment_delta_xy[None, :, :] + ) + closest_dist_sq = (chunk_xy[:, None, :] - closest_xy).square().sum(dim=-1) + nearest_segment_idx = closest_dist_sq.argmin(dim=-1) + nearest_t = t[ + torch.arange(chunk_xy.shape[0], device=query_xy.device), + nearest_segment_idx, + ] + nearest_z[chunk_start:chunk_stop] = segment_start_z[nearest_segment_idx] + ( + nearest_t * segment_delta_z[nearest_segment_idx] + ) + return nearest_z diff --git a/src/trafficsim/alpasim_trafficsim/grpc/pipeline/response_builder.py b/src/trafficsim/alpasim_trafficsim/grpc/pipeline/response_builder.py new file mode 100644 index 00000000..b63f1096 --- /dev/null +++ b/src/trafficsim/alpasim_trafficsim/grpc/pipeline/response_builder.py @@ -0,0 +1,183 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation + +import math + +import numpy as np +import torch +from alpasim_grpc.v0 import common_pb2, traffic_pb2 +from alpasim_trafficsim.grpc.service_structures import SimEnvData +from alpasim_utils.geometry import Trajectory, pose_to_grpc_at_time +from loguru import logger + +from .env_builder import sample_start_timestamp_us, step_idx_to_timestamp_us + + +def build_agent_pose_at_timestamp( + env_data: SimEnvData, + *, + agent_idx: int, + timestamp_us: int, + dt_us: int, +) -> common_pb2.PoseAtTime | None: + valid_step_indices = ( + torch.nonzero( + env_data["agents"]["valid_mask"][agent_idx], + as_tuple=False, + ) + .flatten() + .detach() + .cpu() + .tolist() + ) + if not valid_step_indices: + return None + + sample_start_t_us = sample_start_timestamp_us(env_data) + timestamps_us = np.asarray( + [ + sample_start_t_us + (int(step_idx) * dt_us) + for step_idx in valid_step_indices + ], + dtype=np.uint64, + ) + positions = ( + env_data["agents"]["xyz"][agent_idx, valid_step_indices, :] + .detach() + .cpu() + .numpy() + .astype(np.float32, copy=False) + ) + headings = ( + env_data["agents"]["heading"][agent_idx, valid_step_indices] + .detach() + .cpu() + .numpy() + ) + quaternions = np.zeros((len(valid_step_indices), 4), dtype=np.float32) + half_yaws = 0.5 * headings + quaternions[:, 2] = np.sin(half_yaws) + quaternions[:, 3] = np.cos(half_yaws) + trajectory = Trajectory(timestamps_us, positions, quaternions) + try: + pose = trajectory.interpolate_pose(int(timestamp_us)) + except ValueError: + return None + return pose_to_grpc_at_time(pose, int(timestamp_us)) + + +def build_agent_updates_from_env( + env_data: SimEnvData, + *, + timestamp_us: int, + forecast_end_timestamp_us: int | None = None, + dt_us: int, +) -> list[traffic_pb2.ObjectTrajectoryUpdate]: + updates: list[traffic_pb2.ObjectTrajectoryUpdate] = [] + num_agents = env_data["agents"]["xyz"].shape[0] + agent_object_ids = env_data["env"].get("agent_object_ids") + if agent_object_ids is None: + agent_object_ids = [ + str(int(track_id)) + for track_id in env_data["agents"]["track_ids"].detach().cpu().tolist() + ] + agent_is_static = env_data["env"].get("agent_is_static") + if agent_is_static is None: + static_mask = [False] * num_agents + else: + static_mask = [bool(v) for v in agent_is_static] + if len(static_mask) != num_agents: + static_mask = [False] * num_agents + + sample_start_t_us = sample_start_timestamp_us(env_data) + forecast_timestamps_us = [int(timestamp_us)] + if ( + forecast_end_timestamp_us is not None + and forecast_end_timestamp_us > timestamp_us + ): + first_step_idx = math.floor((int(timestamp_us) - sample_start_t_us) / dt_us) + 1 + last_step_idx = math.floor( + (int(forecast_end_timestamp_us) - sample_start_t_us) / dt_us + ) + forecast_timestamps_us.extend( + sample_start_t_us + (step_idx * dt_us) + for step_idx in range(max(first_step_idx, 0), last_step_idx + 1) + if sample_start_t_us + (step_idx * dt_us) > int(timestamp_us) + ) + + for agent_idx in range(num_agents): + timestamps_for_agent = ( + [int(timestamp_us)] if static_mask[agent_idx] else forecast_timestamps_us + ) + poses_at_time = [ + pose_at_time + for ts_us in timestamps_for_agent + if ( + pose_at_time := build_agent_pose_at_timestamp( + env_data, + agent_idx=agent_idx, + timestamp_us=ts_us, + dt_us=dt_us, + ) + ) + is not None + ] + if not poses_at_time: + continue + trajectory = common_pb2.Trajectory(poses=poses_at_time) + updates.append( + traffic_pb2.ObjectTrajectoryUpdate( + object_id=str(agent_object_ids[agent_idx]), + trajectory=trajectory, + ) + ) + return updates + + +def build_simulation_response( + *, + session_uuid: str, + env_data: SimEnvData, + query_ts_us: int, + future_step_indices: list[int], + forecast_step_indices: list[int] | None = None, + dt_us: int, + minimum_history_length: int, +) -> tuple[traffic_pb2.TrafficReturn, int]: + current_step_idx = int(env_data["env"]["curr_t"]) + current_ts_us = step_idx_to_timestamp_us( + env_data, + current_step_idx, + dt_us=dt_us, + ) + env_data["current_time_us"] = torch.tensor([[current_ts_us]], dtype=torch.long) + forecast_end_ts_us = None + if forecast_step_indices: + forecast_end_ts_us = step_idx_to_timestamp_us( + env_data, + forecast_step_indices[-1], + dt_us=dt_us, + ) + response_updates = build_agent_updates_from_env( + env_data, + timestamp_us=query_ts_us, + forecast_end_timestamp_us=forecast_end_ts_us, + dt_us=dt_us, + ) + logger.debug( + "catk_closed_loop_response: session={} requested_ts_us={} " + "current_ts_us={} request_gap_us={} response_updates={} " + "future_steps={} forecast_steps={} history_steps={}", + session_uuid, + query_ts_us, + current_ts_us, + query_ts_us - current_ts_us, + len(response_updates), + len(future_step_indices), + len(forecast_step_indices or []), + minimum_history_length, + ) + return ( + traffic_pb2.TrafficReturn(object_trajectory_updates=response_updates), + current_ts_us, + ) diff --git a/src/trafficsim/alpasim_trafficsim/grpc/service_structures.py b/src/trafficsim/alpasim_trafficsim/grpc/service_structures.py new file mode 100644 index 00000000..13d4b202 --- /dev/null +++ b/src/trafficsim/alpasim_trafficsim/grpc/service_structures.py @@ -0,0 +1,60 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, TypedDict + +import torch +from alpasim_utils.geometry import Trajectory + + +class EgoEnvData(TypedDict, total=False): + xyz: torch.Tensor # (T, 3) + heading: torch.Tensor # (T,) + lwh: torch.Tensor # (3,) + + +class AgentEnvData(TypedDict, total=False): + xyz: torch.Tensor # (num_agents, T, 3) + heading: torch.Tensor # (num_agents, T) + valid_mask: torch.Tensor # (num_agents, T) + lwh: torch.Tensor # (num_agents, 3) + track_ids: torch.Tensor + class_ids: torch.Tensor + num_obstacles: int + + +class EnvMetadata(TypedDict, total=False): + curr_t: int + sample_start_t_us: int + agent_object_ids: list[str] + agent_is_static: list[bool] + + +class SimEnvData(TypedDict, total=False): + ego: EgoEnvData + agents: AgentEnvData + env: EnvMetadata + map: dict[str, Any] + metadata: dict[str, Any] + current_time_us: torch.Tensor + + +@dataclass +class SessionState: + """Mutable per-session traffic state. + + ``closed_loop_trajectories`` is seeded from logged trajectories and then + mutated as runtime updates and CATK predictions arrive. It is the durable + history used to resample each CATK input window. ``env_data`` is the current + resampled CATK working window and may be rebuilt on each request. + """ + + session_uuid: str + scene_id: str + current_ts_us: int + closed_loop_trajectories: dict[str, Trajectory] + env_data: SimEnvData + handover_time_us: int diff --git a/src/trafficsim/alpasim_trafficsim/grpc/servicer.py b/src/trafficsim/alpasim_trafficsim/grpc/servicer.py new file mode 100644 index 00000000..d3f03313 --- /dev/null +++ b/src/trafficsim/alpasim_trafficsim/grpc/servicer.py @@ -0,0 +1,442 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation + +import math +import threading +from pathlib import Path +from typing import Any, Optional + +from alpasim_grpc.v0 import common_pb2, traffic_pb2, traffic_pb2_grpc +from alpasim_runtime.errors import UnknownSceneError +from alpasim_runtime.scene_loader import ArtifactSceneProvider, SceneLoader +from alpasim_trafficsim.catk.scene_adapter import ( + CATKSceneAdapter, + preprocess_runtime_map, +) +from alpasim_trafficsim.grpc import API_VERSION_MESSAGE, VersionId +from alpasim_trafficsim.grpc.catk_predictor import CATKTrafficPredictor +from alpasim_trafficsim.grpc.config import CatkConfig, CatkLoaderConfig +from alpasim_trafficsim.grpc.pipeline.env_builder import ( + InsufficientEgoTrajectoryError, + ensure_time_axis_length, + populate_ego_future_from_trajectory, +) +from alpasim_trafficsim.grpc.pipeline.response_builder import build_simulation_response +from alpasim_trafficsim.grpc.service_structures import SessionState, SimEnvData +from alpasim_trafficsim.grpc.session import factory +from alpasim_trafficsim.grpc.session.history import ( + build_resampled_env_data, + merge_env_step_trajectories, + merge_object_trajectory_updates, +) +from alpasim_utils.geometry import trajectory_from_grpc +from loguru import logger + +import grpc + + +def _gap_seconds(later_us: int | None, earlier_us: int | None) -> float | None: + if later_us is None or earlier_us is None: + return None + return (int(later_us) - int(earlier_us)) / 1e6 + + +def _prediction_step_count(actions: dict[str, Any], *, fallback: int) -> int: + pred_xyz = actions.get("agent_future_xyz") + if pred_xyz is None or not hasattr(pred_xyz, "shape") or len(pred_xyz.shape) < 2: + return fallback + return max(int(pred_xyz.shape[1]), fallback) + + +def preprocess_session_map( + env_data: SimEnvData, + *, + loader_cfg: CatkLoaderConfig, + minimum_history_length: int, +) -> None: + """Apply TarCache-style runtime map preprocessing in place.""" + curr_t = int(env_data["env"].get("curr_t", minimum_history_length - 1)) + curr_t = max(curr_t, 0) + preprocess_runtime_map( + env_data["map"], + ego_xyz=env_data["ego"]["xyz"][: curr_t + 1], + agents_xyz=env_data["agents"]["xyz"][:, : curr_t + 1], + agents_valid_mask=env_data["agents"]["valid_mask"][:, : curr_t + 1], + map_element_names=loader_cfg.map_element_names, + map_polyline_filter_mode=loader_cfg.map_polyline_filter_mode, + map_max_pts_to_ego_distance=loader_cfg.map_max_pts_to_ego_distance, + map_polyline_number_control_mode=loader_cfg.map_polyline_number_control_mode, + map_adv_max_lane_polylines_num=loader_cfg.map_adv_max_lane_polylines_num, + map_adv_max_road_boundary_num=loader_cfg.map_adv_max_road_boundary_num, + map_adv_max_other_polylines_num=loader_cfg.map_adv_max_other_polylines_num, + ) + + +class CatkPredictionUnavailableError(RuntimeError): + """Raised when CATK cannot produce predictions for the current request.""" + + +class TrafficServiceServicer(traffic_pb2_grpc.TrafficServiceServicer): + def __init__( + self, + server: Optional[grpc.Server] = None, + *, + catk_config: CatkConfig, + usdz_folder: str | Path, + service_version: str = "simple-traffic-service", + ) -> None: + self._server = server + self._lock = threading.Lock() + self._sessions: dict[str, SessionState] = {} + self._service_version = service_version + self._time_step_s = catk_config.loader.time_step + self._dt_us = int(round(self._time_step_s * 1e6)) + self._loader_cfg = catk_config.loader + self._minimum_future_steps = self._loader_cfg.minimum_future_steps + self._minimum_history_length = self._loader_cfg.num_history_steps + self._scene_loader = SceneLoader( + ArtifactSceneProvider.from_path( + usdz_folder, + smooth_trajectories=False, + ) + ) + self._scene_adapter = CATKSceneAdapter( + num_history_steps=self._loader_cfg.num_history_steps, + motion_stepsize=self._loader_cfg.time_step, + map_distance_x=self._loader_cfg.map_distance_x, + map_distance_y=self._loader_cfg.map_distance_y, + map_polyline_length_k=self._loader_cfg.map_polyline_length_k, + map_resample_interval_m=self._loader_cfg.map_resample_interval_m, + ) + self._catk_predictor = CATKTrafficPredictor(catk_config) + + def _preprocess_session_map(self, env_data: SimEnvData) -> None: + preprocess_session_map( + env_data, + loader_cfg=self._loader_cfg, + minimum_history_length=self._minimum_history_length, + ) + + def _future_step_indices_from_history_window( + self, + *, + current_step_idx: int, + current_ts_us: int, + query_ts_us: int, + ) -> list[int]: + if query_ts_us <= current_ts_us: + return [] + num_future_steps = math.ceil((query_ts_us - current_ts_us) / self._dt_us) + return list( + range(current_step_idx + 1, current_step_idx + num_future_steps + 1) + ) + + def start_session( + self, + request: traffic_pb2.TrafficSessionRequest, + context: grpc.ServicerContext, + ) -> common_pb2.SessionRequestStatus: + if not request.session_uuid: + context.set_code(grpc.StatusCode.INVALID_ARGUMENT) + context.set_details("session_uuid is required") + return common_pb2.SessionRequestStatus() + if not request.scene_id: + context.set_code(grpc.StatusCode.INVALID_ARGUMENT) + context.set_details("scene_id is required") + return common_pb2.SessionRequestStatus() + if not request.logged_object_trajectories: + context.set_code(grpc.StatusCode.INVALID_ARGUMENT) + context.set_details("at least one logged_object_trajectory is required") + return common_pb2.SessionRequestStatus() + + try: + data_source = self._scene_loader.get_data_source(request.scene_id) + base_env_data = self._scene_adapter.load(data_source) + except (KeyError, ValueError, FileNotFoundError, UnknownSceneError) as exc: + context.set_code(grpc.StatusCode.NOT_FOUND) + context.set_details( + f"scene_id {request.scene_id!r} could not be loaded: {exc}" + ) + return common_pb2.SessionRequestStatus() + + try: + session_state = factory.build_session_state( + request, + base_env_data=base_env_data, + dt_us=self._dt_us, + minimum_history_length=self._minimum_history_length, + ) + env_data = session_state.env_data + self._preprocess_session_map(env_data) + except (KeyError, ValueError) as exc: + context.set_code(grpc.StatusCode.INVALID_ARGUMENT) + context.set_details(f"could not build session from request: {exc}") + return common_pb2.SessionRequestStatus() + + first_ego_pose_ts_us = factory.first_ego_pose_ts_us( + session_state.closed_loop_trajectories + ) + initial_ts_us = session_state.current_ts_us + num_static_agents = sum( + bool(v) for v in env_data["env"].get("agent_is_static", []) + ) + + with self._lock: + self._sessions[request.session_uuid] = session_state + + logger.info( + "start_session: session={} scene_id={} initial_ts_us={} first_ego_pose_ts_us={} " + "handover_time_us={} handover_minus_first_ego_s={} " + "handover_minus_initial_s={} initial_minus_first_ego_s={} " + "random_seed={} num_logged_objects={} num_static_agents={}", + request.session_uuid, + request.scene_id, + initial_ts_us, + first_ego_pose_ts_us, + session_state.handover_time_us, + _gap_seconds(session_state.handover_time_us, first_ego_pose_ts_us), + _gap_seconds(session_state.handover_time_us, initial_ts_us), + _gap_seconds(initial_ts_us, first_ego_pose_ts_us), + request.random_seed, + len(request.logged_object_trajectories), + num_static_agents, + ) + return common_pb2.SessionRequestStatus() + + def close_session( + self, + request: traffic_pb2.TrafficSessionCloseRequest, + context: grpc.ServicerContext, + ) -> common_pb2.Empty: + with self._lock: + removed = self._sessions.pop(request.session_uuid, None) + + if removed is None: + context.set_code(grpc.StatusCode.NOT_FOUND) + context.set_details(f"Unknown session_uuid: {request.session_uuid}") + return common_pb2.Empty() + + logger.info("close_session: session={}", request.session_uuid) + return common_pb2.Empty() + + def _apply_model_predictions( + self, + *, + session_uuid: str, + query_ts_us: int, + session_state: SessionState, + future_step_indices: list[int], + ) -> list[int]: + if not future_step_indices: + return [] + + env_data = session_state.env_data + current_step_idx = int(env_data["env"].get("curr_t", 0)) + predict_steps = max(len(future_step_indices), self._minimum_future_steps) + actions = self._catk_predictor.run_inference( + env_data, + predict_steps=predict_steps, + ) + if actions is None: + raise CatkPredictionUnavailableError( + "CATK did not produce predictions for the current request" + ) + + forecast_steps = _prediction_step_count(actions, fallback=predict_steps) + forecast_step_indices = list( + range(current_step_idx + 1, current_step_idx + forecast_steps + 1) + ) + for step_idx in forecast_step_indices: + ensure_time_axis_length(env_data, step_idx) + applied_step_indices = self._catk_predictor.apply_predictions_to_env( + session_state, + future_step_indices=forecast_step_indices, + actions=actions, + ) + if applied_step_indices is None: + applied_step_indices = forecast_step_indices + env_data["env"]["curr_t"] = future_step_indices[-1] + return list(applied_step_indices) + + def _simulate_logged_replay( + self, + *, + session_state: SessionState, + session_uuid: str, + query_ts_us: int, + ) -> traffic_pb2.TrafficReturn: + env_data = build_resampled_env_data( + session_state, + end_ts_us=query_ts_us, + history_steps=self._minimum_history_length, + dt_us=self._dt_us, + ) + session_state.env_data = env_data + result, _response_current_ts_us = build_simulation_response( + session_uuid=session_uuid, + env_data=env_data, + query_ts_us=query_ts_us, + future_step_indices=[], + dt_us=self._dt_us, + minimum_history_length=self._minimum_history_length, + ) + session_state.current_ts_us = int(query_ts_us) + return result + + def simulate( + self, + request: traffic_pb2.TrafficRequest, + context: grpc.ServicerContext, + ) -> traffic_pb2.TrafficReturn: + with self._lock: + query_ts_us = request.time_query_us + session_state = self._sessions.get(request.session_uuid) + if session_state is None: + context.set_code(grpc.StatusCode.NOT_FOUND) + context.set_details(f"Unknown session_uuid: {request.session_uuid}") + return traffic_pb2.TrafficReturn() + + request_update_trajectories = { + update.object_id: trajectory_from_grpc(update.trajectory) + for update in request.object_trajectory_updates + } + merge_object_trajectory_updates( + session_state.closed_loop_trajectories, + request.object_trajectory_updates, + ) + + if query_ts_us <= session_state.handover_time_us: + return self._simulate_logged_replay( + session_state=session_state, + session_uuid=request.session_uuid, + query_ts_us=query_ts_us, + ) + + history_end_ts_us = int(session_state.current_ts_us) + if history_end_ts_us < session_state.handover_time_us < int(query_ts_us): + history_end_ts_us = session_state.handover_time_us + + try: + env_data = build_resampled_env_data( + session_state, + end_ts_us=history_end_ts_us, + history_steps=self._minimum_history_length, + dt_us=self._dt_us, + ) + session_state.env_data = env_data + current_step_idx = self._minimum_history_length - 1 + + future_step_indices = self._future_step_indices_from_history_window( + current_step_idx=current_step_idx, + current_ts_us=history_end_ts_us, + query_ts_us=query_ts_us, + ) + ego_trajectory = ( + request_update_trajectories.get("EGO") + or session_state.closed_loop_trajectories["EGO"] + ) + populate_ego_future_from_trajectory( + env_data, + ego_trajectory, + current_step_idx=current_step_idx, + requested_timestamp_us=query_ts_us, + future_step_indices=future_step_indices, + dt_us=self._dt_us, + ) + + forecast_step_indices = self._apply_model_predictions( + session_uuid=request.session_uuid, + query_ts_us=query_ts_us, + session_state=session_state, + future_step_indices=future_step_indices, + ) + + result, _response_current_ts_us = build_simulation_response( + session_uuid=request.session_uuid, + env_data=env_data, + query_ts_us=query_ts_us, + future_step_indices=future_step_indices, + forecast_step_indices=forecast_step_indices, + dt_us=self._dt_us, + minimum_history_length=self._minimum_history_length, + ) + merge_env_step_trajectories( + session_state.closed_loop_trajectories, + env_data, + step_indices=future_step_indices, + dt_us=self._dt_us, + include_ego=True, + ) + merge_env_step_trajectories( + session_state.closed_loop_trajectories, + env_data, + step_indices=forecast_step_indices, + dt_us=self._dt_us, + include_ego=False, + ) + merge_object_trajectory_updates( + session_state.closed_loop_trajectories, + result.object_trajectory_updates, + ) + except CatkPredictionUnavailableError as exc: + logger.warning( + "simulate rejected: session={} query_ts_us={} reason={}", + request.session_uuid, + query_ts_us, + exc, + ) + context.set_code(grpc.StatusCode.FAILED_PRECONDITION) + context.set_details(str(exc)) + return traffic_pb2.TrafficReturn() + except InsufficientEgoTrajectoryError as exc: + logger.warning( + "simulate rejected: session={} query_ts_us={} reason={}", + request.session_uuid, + query_ts_us, + exc, + ) + context.set_code(grpc.StatusCode.INVALID_ARGUMENT) + context.set_details(str(exc)) + return traffic_pb2.TrafficReturn() + except Exception as exc: # noqa: BLE001 - surface as a gRPC status + logger.exception( + "simulate failed: session={} query_ts_us={}", + request.session_uuid, + query_ts_us, + ) + context.set_code(grpc.StatusCode.INTERNAL) + context.set_details(f"simulation step failed: {exc}") + return traffic_pb2.TrafficReturn() + + session_state.current_ts_us = int(query_ts_us) + return result + + def get_metadata( + self, + request: common_pb2.Empty, + context: grpc.ServicerContext, + ) -> traffic_pb2.TrafficModuleMetadata: + del request, context + return traffic_pb2.TrafficModuleMetadata( + version_id=VersionId( + version_id=self._service_version, + git_hash="", + grpc_api_version=API_VERSION_MESSAGE, + ), + minimum_history_length_us=self._minimum_history_length * self._dt_us, + ) + + def get_available_scenes( + self, + request: common_pb2.Empty, + context: grpc.ServicerContext, + ) -> common_pb2.AvailableScenesReturn: + del request, context + return common_pb2.AvailableScenesReturn(scene_ids=self._scene_loader.scene_ids) + + def shut_down(self, request, context): + context.add_callback(self._shut_down) + return common_pb2.Empty() + + def _shut_down(self): + self._server.stop(0) diff --git a/src/trafficsim/alpasim_trafficsim/grpc/session/__init__.py b/src/trafficsim/alpasim_trafficsim/grpc/session/__init__.py new file mode 100644 index 00000000..7bd5f4ce --- /dev/null +++ b/src/trafficsim/alpasim_trafficsim/grpc/session/__init__.py @@ -0,0 +1,10 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation + +"""Stateful session/timeline orchestration built on top of ``grpc.pipeline``.""" + +from .factory import build_session_state + +__all__ = [ + "build_session_state", +] diff --git a/src/trafficsim/alpasim_trafficsim/grpc/session/factory.py b/src/trafficsim/alpasim_trafficsim/grpc/session/factory.py new file mode 100644 index 00000000..23ac99a5 --- /dev/null +++ b/src/trafficsim/alpasim_trafficsim/grpc/session/factory.py @@ -0,0 +1,70 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation + +"""Construction of a fully-seeded ``SessionState`` from request inputs. + +Keeps ``start_session`` thin: given the loaded base env data and the logged +trajectories, this builds the per-session env, computes the initial history +anchor, constructs the session, and seeds the maintained CATK history window. +""" + +from alpasim_grpc.v0 import traffic_pb2 +from alpasim_trafficsim.grpc.pipeline.env_builder import build_session_env_data +from alpasim_trafficsim.grpc.service_structures import SessionState, SimEnvData +from alpasim_utils.geometry import Trajectory, trajectory_from_grpc + +from .history import build_resampled_env_data + + +def first_ego_pose_ts_us(logged_trajectories: dict[str, Trajectory]) -> int: + ego_logged_trajectory = logged_trajectories.get("EGO") + if ego_logged_trajectory is None: + raise ValueError("logged_object_trajectories must include an EGO trajectory") + if ego_logged_trajectory.is_empty(): + raise ValueError("logged EGO trajectory must contain at least one pose") + return int(ego_logged_trajectory.timestamps_us[0]) + + +def build_session_state( + request: traffic_pb2.TrafficSessionRequest, + *, + base_env_data: SimEnvData, + dt_us: int, + minimum_history_length: int, +) -> SessionState: + """Build a ``SessionState`` for ``start_session``.""" + logged_object_trajectories = list(request.logged_object_trajectories) + logged_trajectories = { + logged_object.object_id: trajectory_from_grpc(logged_object.trajectory) + for logged_object in logged_object_trajectories + } + ego_ts_us = first_ego_pose_ts_us(logged_trajectories) + + env_data = build_session_env_data( + base_env_data=base_env_data, + logged_object_trajectories=logged_object_trajectories, + ) + + history_end_idx = minimum_history_length - 1 + initial_ts_us = ego_ts_us + history_end_idx * dt_us + + handover_time_us = int(request.handover_time_us) + if handover_time_us <= 0: + raise ValueError("handover_time_us must be positive") + + session_state = SessionState( + session_uuid=request.session_uuid, + scene_id=request.scene_id, + current_ts_us=initial_ts_us, + handover_time_us=handover_time_us, + closed_loop_trajectories=logged_trajectories, + env_data=env_data, + ) + session_state.env_data = build_resampled_env_data( + session_state, + end_ts_us=initial_ts_us, + history_steps=minimum_history_length, + dt_us=dt_us, + ) + + return session_state diff --git a/src/trafficsim/alpasim_trafficsim/grpc/session/history.py b/src/trafficsim/alpasim_trafficsim/grpc/session/history.py new file mode 100644 index 00000000..2c11a778 --- /dev/null +++ b/src/trafficsim/alpasim_trafficsim/grpc/session/history.py @@ -0,0 +1,189 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation + +"""Traffic session trajectory history and env resampling helpers.""" + +import numpy as np +from alpasim_trafficsim.grpc.pipeline.env_builder import ( + agent_is_static_by_object_id, + agent_object_id_to_index, + first_trajectory_pose_at_timestamp, + reset_env_dynamic_state, + snapshot_dynamic_env_data, + step_idx_to_timestamp_us, + write_pose_to_env, +) +from alpasim_trafficsim.grpc.service_structures import SessionState, SimEnvData +from alpasim_utils.geometry import ( + Trajectory, + pose_to_grpc_at_time, + trajectory_from_grpc, +) + + +def _trajectory_from_env_samples( + timestamps_us: list[int], + xyz, + heading, +) -> Trajectory: + positions = xyz.detach().cpu().numpy().astype(np.float32, copy=False) + headings = heading.detach().cpu().numpy() + quaternions = np.zeros((len(timestamps_us), 4), dtype=np.float32) + half_yaws = 0.5 * headings + quaternions[:, 2] = np.sin(half_yaws) + quaternions[:, 3] = np.cos(half_yaws) + return Trajectory( + np.asarray(timestamps_us, dtype=np.uint64), + positions, + quaternions, + ) + + +def merge_trajectory( + trajectories: dict[str, Trajectory], + *, + object_id: str, + trajectory: Trajectory, +) -> None: + object_id = str(object_id) + if trajectory.is_empty(): + return + target = trajectories.get(object_id) + if target is None: + trajectories[object_id] = trajectory.clone() + return + + first_incoming_ts_us = int(trajectory.timestamps_us[0]) + retained_target = target.filter(target.timestamps_us < first_incoming_ts_us) + trajectories[object_id] = retained_target.append(trajectory) + + +def merge_object_trajectory_updates( + trajectories: dict[str, Trajectory], + updates, +) -> None: + for update in updates: + merge_trajectory( + trajectories, + object_id=str(update.object_id), + trajectory=trajectory_from_grpc(update.trajectory), + ) + + +def merge_env_step_trajectories( + trajectories: dict[str, Trajectory], + env_data: SimEnvData, + *, + step_indices: list[int], + dt_us: int, + include_ego: bool, +) -> None: + if not step_indices: + return + + step_indices = [int(step_idx) for step_idx in step_indices] + timestamps_by_step_idx = { + step_idx: step_idx_to_timestamp_us(env_data, step_idx, dt_us=dt_us) + for step_idx in step_indices + } + + if include_ego: + ego_steps = [ + step_idx + for step_idx in step_indices + if step_idx < env_data["ego"]["xyz"].shape[0] + ] + if ego_steps: + merge_trajectory( + trajectories, + object_id="EGO", + trajectory=_trajectory_from_env_samples( + [timestamps_by_step_idx[step_idx] for step_idx in ego_steps], + env_data["ego"]["xyz"][ego_steps, :], + env_data["ego"]["heading"][ego_steps], + ), + ) + + num_agents = int(env_data["agents"]["xyz"].shape[0]) + agent_object_ids = env_data["env"].get("agent_object_ids") + if agent_object_ids is None: + agent_object_ids = [ + str(int(track_id)) + for track_id in env_data["agents"]["track_ids"].detach().cpu().tolist() + ] + + for agent_idx in range(num_agents): + agent_steps = [ + step_idx + for step_idx in step_indices + if step_idx < env_data["agents"]["valid_mask"].shape[1] + and bool(env_data["agents"]["valid_mask"][agent_idx, step_idx].item()) + ] + if not agent_steps: + continue + merge_trajectory( + trajectories, + object_id=str(agent_object_ids[agent_idx]), + trajectory=_trajectory_from_env_samples( + [timestamps_by_step_idx[step_idx] for step_idx in agent_steps], + env_data["agents"]["xyz"][agent_idx, agent_steps, :], + env_data["agents"]["heading"][agent_idx, agent_steps], + ), + ) + + +def build_resampled_env_data( + session_state: SessionState, + *, + end_ts_us: int, + history_steps: int, + dt_us: int, +) -> SimEnvData: + history_steps = max(int(history_steps), 1) + end_ts_us = int(end_ts_us) + start_ts_us = end_ts_us - ((history_steps - 1) * dt_us) + + resampled_env_data = snapshot_dynamic_env_data(session_state.env_data) + reset_env_dynamic_state( + resampled_env_data, + total_steps=history_steps, + curr_t=history_steps - 1, + sample_start_t_us=start_ts_us, + dt_us=dt_us, + ) + + static_by_object_id = agent_is_static_by_object_id(resampled_env_data) + object_id_to_idx = agent_object_id_to_index(resampled_env_data) + object_ids = [ + "EGO", + *[str(v) for v in resampled_env_data["env"].get("agent_object_ids", [])], + ] + for object_id in object_ids: + trajectory = session_state.closed_loop_trajectories.get(str(object_id)) + if trajectory is None: + continue + for step_idx in range(history_steps): + timestamp_us = start_ts_us + (step_idx * dt_us) + try: + pose = pose_to_grpc_at_time( + trajectory.interpolate_pose(int(timestamp_us)), + int(timestamp_us), + ) + except ValueError: + pose = None + if pose is None and static_by_object_id.get(str(object_id), False): + pose = first_trajectory_pose_at_timestamp( + trajectory, + timestamp_us=timestamp_us, + ) + if pose is None: + continue + write_pose_to_env( + resampled_env_data, + object_id=str(object_id), + step_idx=step_idx, + pose_at_time=pose, + agent_object_id_to_idx=object_id_to_idx, + ) + + return resampled_env_data diff --git a/src/trafficsim/pyproject.toml b/src/trafficsim/pyproject.toml index 9a07cbbd..01206614 100644 --- a/src/trafficsim/pyproject.toml +++ b/src/trafficsim/pyproject.toml @@ -1,5 +1,53 @@ +[build-system] +requires = ["setuptools>=64"] +build-backend = "setuptools.build_meta" + [project] name = "alpasim-trafficsim" -version = "0.1.0" +version = "0.2.0" description = "Traffic simulation micro-service for Alpasim" requires-python = ">=3.11,<3.13" +dependencies = [ + "alpasim_grpc", + "alpasim-runtime", + "alpasim-utils[geometry]", + "trajdata-alpasim", + "grpcio>=1.70.0", + "torch", + "torch-geometric", + "numpy", + "omegaconf", + "hydra-core", + "pyyaml", + "loguru", +] + +# NOTE: torch-cluster, torch-scatter, torch-sparse (PyG compiled extensions) +# are required at runtime but need matching CUDA at build time. +# Install them in Docker via pre-built wheels from data.pyg.org. + +[dependency-groups] +dev = [ + "pytest>=7.0.0", + "ruff>=0.5.0", +] + +[project.scripts] +catk_trafficsim_server = "alpasim_trafficsim.grpc.catk_trafficsim:main" + +[tool.uv.sources] +alpasim_grpc = {workspace = true} +alpasim-runtime = {workspace = true} +alpasim-utils = {workspace = true} + +[tool.setuptools] +packages = {find = {where = ["."], include = ["alpasim_trafficsim*"]}} + +[tool.setuptools.package-data] +alpasim_trafficsim = ["config/*.yaml"] + +[tool.pytest.ini_options] +pythonpath = ["."] +markers = [ + "integration: requires external assets (USDZ data, model weights, CUDA, or PyG extensions)", +] diff --git a/src/trafficsim/tests/conftest.py b/src/trafficsim/tests/conftest.py new file mode 100644 index 00000000..ac6a1ffd --- /dev/null +++ b/src/trafficsim/tests/conftest.py @@ -0,0 +1,58 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation + +# pylint: disable=import-error,redefined-outer-name + + +import os +from pathlib import Path + +import pytest + +_PREFERRED_USDZ = "eae31fd6-fbf7-4303-995b-6451f5c303ef.usdz" + + +def _default_data_dir() -> Path: + return Path(__file__).resolve().parent.parent / "data" + + +def _candidate_usdz_dirs(data_dir: Path) -> list[Path]: + candidates: list[Path] = [] + for env_name in ("TRAFFICSIM_USDZ_DIR", "ALPASIM_USDZ_DIR"): + env_value = os.environ.get(env_name) + if env_value: + candidates.append(Path(env_value).expanduser()) + + alpasim_root = Path(__file__).resolve().parents[3] + candidates.extend( + [ + data_dir / "usdz", + alpasim_root / "data" / "nre-artifacts" / "all-usdzs", + alpasim_root / "data" / "nre-artifacts" / "oss" / "all-usdzs", + ] + ) + return candidates + + +@pytest.fixture(scope="session") +def data_dir() -> Path: + return _default_data_dir() + + +@pytest.fixture(scope="session") +def usdz_data_dir(data_dir: Path) -> Path: + candidates = _candidate_usdz_dirs(data_dir) + for candidate in candidates: + if any(candidate.glob("*.usdz")): + return candidate + searched = ", ".join(str(candidate) for candidate in candidates) + pytest.skip(f"No USDZ files found under any configured data path: {searched}") + + +@pytest.fixture(scope="session") +def usdz_from_data_dir(usdz_data_dir: Path) -> Path: + usdz_files = sorted(usdz_data_dir.glob("*.usdz")) + preferred = usdz_data_dir / _PREFERRED_USDZ + if preferred.is_file(): + return preferred + return usdz_files[0] diff --git a/src/trafficsim/tests/test_catk_integration.py b/src/trafficsim/tests/test_catk_integration.py new file mode 100644 index 00000000..88666c9d --- /dev/null +++ b/src/trafficsim/tests/test_catk_integration.py @@ -0,0 +1,453 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation + +"""Integration tests for the trafficsim CATK model. + +Run with: + uv run pytest src/trafficsim/tests/test_catk_integration.py -v -s + +Tests: + test_catk_synthetic — Loads CATK model + weights, runs inference on synthetic + env_data (no USDZ needed). Requires CUDA + torch-cluster + + model weights in data/trafficsim-models/. + + test_catk_scene_adapter — Loads a ClipGT USDZ through the trafficsim decoder. + Requires a ClipGT-format USDZ (set TRAFFICSIM_TEST_USDZ). + + test_catk_inference — Full end-to-end: ClipGT USDZ → CATK model → predictions. + Requires both USDZ data and model weights. + +Environment variables: + TRAFFICSIM_MODELS_DIR — Path to model weights (default: data/trafficsim-models) + TRAFFICSIM_TEST_USDZ — Path to a single ClipGT .usdz file + TRAFFICSIM_TEST_USDZ_DIR — Directory containing ClipGT .usdz files +""" + + +import os +from pathlib import Path + +import pytest +import torch +from alpasim_trafficsim.catk.obstacle_classes import obstacle_class_metadata + +MAP_ELEMENT_NAME2_TYPEID = { + "lane_lines": 0, + "road_boundaries": 1, +} + +REPO_ROOT = Path(__file__).resolve().parents[3] + +MODELS_DIR = Path( + os.environ.get("TRAFFICSIM_MODELS_DIR", REPO_ROOT / "data" / "trafficsim-models") +) + +_usdz_env = os.environ.get("TRAFFICSIM_TEST_USDZ") +_usdz_dir_env = os.environ.get("TRAFFICSIM_TEST_USDZ_DIR") + + +def _find_usdz() -> Path | None: + if _usdz_env: + p = Path(_usdz_env) + return p if p.is_file() else None + if _usdz_dir_env: + d = Path(_usdz_dir_env) + else: + d = REPO_ROOT / "data" / "trafficsim-test-data" / "usdz" + if d.is_dir(): + usdz_files = sorted(d.glob("*.usdz")) + return usdz_files[0] if usdz_files else None + return None + + +_test_usdz = _find_usdz() + +_skip_no_usdz = pytest.mark.skipif( + _test_usdz is None, + reason="No ClipGT USDZ files found. Set TRAFFICSIM_TEST_USDZ or TRAFFICSIM_TEST_USDZ_DIR.", +) +_skip_no_weights = pytest.mark.skipif( + not (MODELS_DIR / "catk_v120" / "latest.ckpt").is_file(), + reason=f"CATK weights not found in {MODELS_DIR}", +) +_skip_no_cuda = pytest.mark.skipif( + not torch.cuda.is_available(), + reason="CUDA not available", +) + + +def _try_import_torch_cluster() -> bool: + try: + __import__("torch_cluster") + + return True + except ImportError: + return False + + +_skip_no_torch_cluster = pytest.mark.skipif( + not _try_import_torch_cluster(), + reason="torch-cluster not installed (requires matching CUDA toolkit)", +) + + +def _make_synthetic_env_data( + num_agents: int = 3, + num_timesteps: int = 80, + num_history: int = 16, + num_polylines: int = 20, + num_points_per_polyline: int = 51, +) -> dict: + """Build a minimal env_data dict with plausible shapes for CATK. + + Creates a straight-road scene with the ego driving forward and a few + agents at fixed offsets. + """ + dt = 0.1 # 10 Hz + times = torch.arange(num_timesteps, dtype=torch.float32) * dt + + # Ego drives forward along +x at ~10 m/s + ego_xyz = torch.zeros(num_timesteps, 3) + ego_xyz[:, 0] = times * 10.0 # x = velocity * t + ego_heading = torch.zeros(num_timesteps) # heading = 0 (along +x) + ego_lwh = torch.tensor([4.5, 2.0, 1.6]) + + # Agents: offset laterally and moving forward at slightly different speeds + agents_xyz = torch.zeros(num_agents, num_timesteps, 3) + agents_heading = torch.zeros(num_agents, num_timesteps) + agents_valid = torch.ones(num_agents, num_timesteps, dtype=torch.bool) + agents_lwh = ( + torch.tensor([4.5, 2.0, 1.6]).unsqueeze(0).expand(num_agents, 3).clone() + ) + + for i in range(num_agents): + lateral_offset = (i + 1) * 3.5 # 3.5m lane width + speed = 8.0 + i * 2.0 + agents_xyz[i, :, 0] = times * speed + 10.0 # ahead of ego + agents_xyz[i, :, 1] = lateral_offset + + # Map: parallel lane lines along +x + map_data = {} + polylines_per_element = num_polylines // 2 + + for element_name, map_type_name in [ + ("lanelines", "lane_lines"), + ("road_boundaries", "road_boundaries"), + ]: + label_id = MAP_ELEMENT_NAME2_TYPEID[map_type_name] + polylines = torch.zeros(polylines_per_element, num_points_per_polyline, 3) + for j in range(polylines_per_element): + xs = torch.linspace(j * 20.0, (j + 1) * 20.0, num_points_per_polyline) + polylines[j, :, 0] = xs + polylines[j, :, 1] = (j % 4) * 3.5 # lane offsets + + map_data[element_name] = { + "polylines": polylines, + "label": [label_id] * polylines_per_element, + } + + return { + "ego": { + "xyz": ego_xyz, + "heading": ego_heading, + "lwh": ego_lwh, + }, + "agents": { + "xyz": agents_xyz, + "heading": agents_heading, + "valid_mask": agents_valid, + "lwh": agents_lwh, + "track_ids": torch.arange(num_agents), + "class_ids": torch.zeros(num_agents, dtype=torch.long), # all "car" + "num_obstacles": num_agents, + }, + "map": map_data, + "env": { + "curr_t": num_history - 1, + }, + "metadata": { + "frame_rate": 10, + **obstacle_class_metadata(), + }, + } + + +def test_freeze_agent_data_includes_static_agents_and_ego() -> None: + from alpasim_trafficsim.catk.env_data_adapter import build_freeze_agent_data + + env_data = _make_synthetic_env_data(num_agents=3, num_timesteps=32) + curr_t = int(env_data["env"]["curr_t"]) + env_data["env"]["agent_is_static"] = [False, True, True] + env_data["agents"]["valid_mask"][2, curr_t] = False + env_data["agents"]["xyz"][1, curr_t] = torch.tensor([11.0, 12.0, 13.0]) + env_data["agents"]["heading"][1, curr_t] = torch.tensor(0.5) + + freeze_data, freeze_mask = build_freeze_agent_data( + env_data, + curr_t=curr_t, + target_steps=6, + dt=0.1, + device="cpu", + ) + + assert freeze_mask.tolist() == [False, True, False, True] + assert freeze_data["num_obstacles"].item() == 2 + torch.testing.assert_close( + freeze_data["agent"]["position"][0], + torch.tensor([[11.0, 12.0, 13.0]]).expand(6, 3), + ) + torch.testing.assert_close( + freeze_data["agent"]["heading"][0], + torch.full((6,), 0.5), + ) + assert freeze_data["agent"]["id"].tolist() == [1, 0] + + +@_skip_no_torch_cluster +def test_world_model_passes_static_and_ego_freeze_mask_to_decoder() -> None: + from alpasim_trafficsim.catk.model_adapter import CATK + + class FakeAgentEncoder: + num_historical_steps = 16 + + def __init__(self) -> None: + self.num_future_steps = 0 + self.captured: dict[str, object] = {} + + def inference_with_mask( + self, + *, + tokenized_agent, + map_feature: object, + sampling_scheme: object, + freeze_agent_future, + freeze_agent_mask, + freeze_tokenized_agent, + ): + _ = map_feature, sampling_scheme + self.captured = { + "freeze_agent_future": freeze_agent_future, + "freeze_agent_mask": freeze_agent_mask.clone(), + "freeze_tokenized_agent": freeze_tokenized_agent, + } + n_agent = int(tokenized_agent["ego_mask"].shape[0]) + n_step = int(self.num_future_steps) + return { + "pred_traj_10hz": torch.zeros((n_agent, n_step, 2)), + "pred_z_10hz": torch.zeros((n_agent, n_step)), + "pred_head_10hz": torch.zeros((n_agent, n_step)), + "pred_valid_10hz": torch.ones((n_agent, n_step), dtype=torch.bool), + } + + class FakeEncoder: + def __init__(self) -> None: + self.agent_encoder = FakeAgentEncoder() + + @staticmethod + def map_encoder(_tokenized_map): + return {} + + class FakeTokenProcessor: + def __init__(self) -> None: + self.freeze_agent_ids: list[int] = [] + + def __call__( + self, + state, + *, + apply_heading_correction: bool, + apply_boundary_extrapolation: bool, + ): + _ = apply_heading_correction, apply_boundary_extrapolation + n_agent = int(state["agent"]["id"].shape[0]) + ego_mask = torch.zeros((n_agent,), dtype=torch.bool) + ego_mask[-1] = True + return {}, {"ego_mask": ego_mask}, None, None + + def tokenize_agent( + self, + data, + *, + apply_heading_correction: bool, + apply_boundary_extrapolation: bool, + ): + _ = apply_heading_correction, apply_boundary_extrapolation + self.freeze_agent_ids = data["agent"]["id"].detach().cpu().tolist() + return { + "gt_pos_raw": data["agent"]["position"][..., :2], + "gt_head_raw": data["agent"]["heading"], + "gt_valid_raw": data["agent"]["valid_mask"], + "gt_idx": torch.zeros_like(data["agent"]["heading"], dtype=torch.long), + }, {} + + class FakeModel: + def __init__(self) -> None: + self.encoder = FakeEncoder() + self.token_processor = FakeTokenProcessor() + self.validation_rollout_sampling = {} + + env_data = _make_synthetic_env_data(num_agents=3, num_timesteps=32) + env_data["env"]["agent_is_static"] = [False, True, False] + + catk = CATK.__new__(CATK) + catk.device = "cpu" + catk.model_input_step_num = 16 + catk.model_predict_step_num = 5 + catk.delta_t = 0.1 + catk.use_downsampled_lines = False + catk.disable_sub_plyline_type = True + catk.model = FakeModel() + + input_data = catk.create_model_input( + env_data, + filter_map_by_ego=False, + filter_distance_th=0.0, + )["input_data"] + catk.inference(input_data) + + agent_encoder = catk.model.encoder.agent_encoder + assert agent_encoder.captured["freeze_agent_future"] is True + assert agent_encoder.captured["freeze_agent_mask"].tolist() == [ + False, + True, + False, + True, + ] + assert catk.model.token_processor.freeze_agent_ids == [1, 0] + + +@pytest.mark.integration +@_skip_no_weights +@_skip_no_cuda +@_skip_no_torch_cluster +def test_catk_synthetic(): + """Load CATK model and run inference on synthetic data (no USDZ needed).""" + from alpasim_trafficsim.catk.model_adapter import CATK + + env_data = _make_synthetic_env_data() + + catk_dir = MODELS_DIR / "catk_v120" + model = CATK( + config_path=str(catk_dir / "config.yaml"), + ckpt_path=str(catk_dir / "latest.ckpt"), + token_pkl_dir=str(MODELS_DIR / "tokens"), + disable_sub_plyline_type=True, + device="cuda", + ) + + result = model.create_model_input( + env_data, filter_map_by_ego=True, filter_distance_th=100.0 + ) + input_data = result["input_data"] + + actions = model.inference(input_data) + + assert "agent_future_xyz" in actions + assert "agent_future_heading" in actions + assert "agent_future_valid_mask" in actions + + num_agents = actions["agent_future_xyz"].shape[0] + num_steps = actions["agent_future_xyz"].shape[1] + + assert actions["agent_future_heading"].shape == (num_agents, num_steps) + assert actions["agent_future_valid_mask"].shape == (num_agents, num_steps) + + valid = actions["agent_future_valid_mask"].bool() + if valid.any(): + valid_xyz = actions["agent_future_xyz"][valid] + assert torch.isfinite( + valid_xyz + ).all(), "Non-finite positions in valid predictions" + + +# --------------------------------------------------------------------------- +# Tests that require ClipGT USDZ files +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +@_skip_no_usdz +def test_catk_scene_adapter(): + """Sanity-check: load a ClipGT USDZ and verify the env_data structure.""" + from alpasim_runtime.scene_loader import ArtifactSceneProvider, SceneLoader + from alpasim_trafficsim.catk.scene_adapter import CATKSceneAdapter + + usdz_path = _test_usdz + + scene_loader = SceneLoader( + ArtifactSceneProvider.from_path( + usdz_path, + smooth_trajectories=False, + ) + ) + scene_id = next(iter(scene_loader.scene_ids)) + env_data = CATKSceneAdapter().load(scene_loader.get_data_source(scene_id)) + + assert "map" in env_data + assert "ego" in env_data + assert "agents" in env_data + assert "env" in env_data + + assert env_data["ego"]["xyz"].ndim == 2 + assert env_data["ego"]["xyz"].shape[1] == 3 + assert env_data["ego"]["heading"].ndim == 1 + assert env_data["env"]["curr_t"] >= 0 + + +@pytest.mark.integration +@_skip_no_usdz +@_skip_no_weights +@_skip_no_cuda +@_skip_no_torch_cluster +def test_catk_inference(): + """Full end-to-end: ClipGT USDZ → CATK model → predictions.""" + from alpasim_runtime.scene_loader import ArtifactSceneProvider, SceneLoader + from alpasim_trafficsim.catk.model_adapter import CATK + from alpasim_trafficsim.catk.scene_adapter import CATKSceneAdapter + + usdz_path = _test_usdz + + scene_loader = SceneLoader( + ArtifactSceneProvider.from_path( + usdz_path, + smooth_trajectories=False, + ) + ) + scene_id = next(iter(scene_loader.scene_ids)) + env_data = CATKSceneAdapter( + num_history_steps=16, + motion_stepsize=0.1, + ).load(scene_loader.get_data_source(scene_id)) + + catk_dir = MODELS_DIR / "catk_v120" + model = CATK( + config_path=str(catk_dir / "config.yaml"), + ckpt_path=str(catk_dir / "latest.ckpt"), + token_pkl_dir=str(MODELS_DIR / "tokens"), + disable_sub_plyline_type=True, + device="cuda", + ) + + result = model.create_model_input( + env_data, filter_map_by_ego=True, filter_distance_th=100.0 + ) + input_data = result["input_data"] + + actions = model.inference(input_data) + + assert "agent_future_xyz" in actions + assert "agent_future_heading" in actions + assert "agent_future_valid_mask" in actions + + num_agents = actions["agent_future_xyz"].shape[0] + num_steps = actions["agent_future_xyz"].shape[1] + + assert actions["agent_future_heading"].shape == (num_agents, num_steps) + assert actions["agent_future_valid_mask"].shape == (num_agents, num_steps) + + valid = actions["agent_future_valid_mask"].bool() + if valid.any(): + valid_xyz = actions["agent_future_xyz"][valid] + assert torch.isfinite( + valid_xyz + ).all(), "Non-finite positions in valid predictions" diff --git a/src/trafficsim/tests/test_laneline_elevation.py b/src/trafficsim/tests/test_laneline_elevation.py new file mode 100644 index 00000000..4b0daa1b --- /dev/null +++ b/src/trafficsim/tests/test_laneline_elevation.py @@ -0,0 +1,193 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation + +from __future__ import annotations + +import pytest +import torch +from alpasim_trafficsim.grpc.catk_predictor import CATKTrafficPredictor +from alpasim_trafficsim.grpc.config import CatkConfig +from alpasim_trafficsim.grpc.pipeline.laneline_elevation import ( + agent_center_z_from_nearest_lanelines, +) +from alpasim_trafficsim.grpc.service_structures import SessionState + + +def _predictor( + *, + predict_static: bool = True, +) -> CATKTrafficPredictor: + cfg = CatkConfig( + predict_static=predict_static, + min_valid_history_steps=0, + ) + predictor = CATKTrafficPredictor.__new__(CATKTrafficPredictor) + predictor.cfg = cfg + predictor.predict_static = cfg.predict_static + predictor.history_window_steps = cfg.loader.num_history_steps + predictor.min_valid_history_steps = cfg.min_valid_history_steps + predictor.model = None + predictor._token_stride = 5 + return predictor + + +def _sloped_map() -> dict: + return { + "lanelines": { + "polylines": torch.tensor( + [ + [[0.0, 0.0, 10.0], [10.0, 0.0, 20.0]], + [[0.0, 10.0, 100.0], [10.0, 10.0, 100.0]], + ], + dtype=torch.float32, + ), + "label": torch.tensor([0, 0], dtype=torch.long), + } + } + + +def test_agent_center_z_from_nearest_lanelines_interpolates_map_height() -> None: + agent_xy = torch.tensor([[5.0, 1.0], [2.0, 9.0], [999.0, 999.0]]) + agent_lwh = torch.tensor( + [ + [4.5, 2.0, 2.0], + [4.5, 2.0, 4.0], + [4.5, 2.0, 6.0], + ], + dtype=torch.float32, + ) + valid_mask = torch.tensor([True, True, False]) + fallback_z = torch.tensor([-1.0, -2.0, -3.0]) + + z = agent_center_z_from_nearest_lanelines( + _sloped_map(), + agent_xy, + agent_lwh=agent_lwh, + valid_mask=valid_mask, + fallback_z=fallback_z, + ) + + torch.testing.assert_close(z, torch.tensor([16.0, 102.0, -3.0])) + + +def test_catk_apply_predictions_sets_agent_z_from_nearest_laneline() -> None: + env_data = { + "env": {"agent_object_ids": ["agent-1"]}, + "map": _sloped_map(), + "ego": { + "xyz": torch.zeros((1, 3), dtype=torch.float32), + "heading": torch.zeros((1,), dtype=torch.float32), + }, + "agents": { + "xyz": torch.zeros((1, 2, 3), dtype=torch.float32), + "heading": torch.zeros((1, 2), dtype=torch.float32), + "valid_mask": torch.tensor([[True, False]]), + "lwh": torch.tensor([[4.5, 2.0, 2.0]], dtype=torch.float32), + "track_ids": torch.tensor([1], dtype=torch.long), + "class_ids": torch.tensor([1], dtype=torch.long), + "num_obstacles": 1, + }, + } + session_state = SessionState( + session_uuid="session-1", + scene_id="clipgt-test-scene", + current_ts_us=0, + closed_loop_trajectories={}, + env_data=env_data, + handover_time_us=1_000_000, + ) + actions = { + "agent_future_xyz": torch.tensor([[[4.0, 1.0, 0.0]]], dtype=torch.float32), + "agent_future_heading": torch.tensor([[0.0]], dtype=torch.float32), + "agent_future_valid_mask": torch.tensor([[True]]), + } + + _predictor().apply_predictions_to_env( + session_state, + future_step_indices=[1], + actions=actions, + ) + + torch.testing.assert_close( + env_data["agents"]["xyz"][0, 1], + torch.tensor([4.0, 1.0, 15.0]), + ) + + +def _static_session_state() -> tuple[SessionState, dict]: + env_data = { + "env": { + "agent_object_ids": ["static-1"], + "agent_is_static": [True], + }, + "map": _sloped_map(), + "ego": { + "xyz": torch.zeros((1, 3), dtype=torch.float32), + "heading": torch.zeros((1,), dtype=torch.float32), + }, + "agents": { + "xyz": torch.zeros((1, 2, 3), dtype=torch.float32), + "heading": torch.zeros((1, 2), dtype=torch.float32), + "valid_mask": torch.tensor([[True, False]]), + "lwh": torch.tensor([[4.5, 2.0, 2.0]], dtype=torch.float32), + "track_ids": torch.tensor([1], dtype=torch.long), + "class_ids": torch.tensor([1], dtype=torch.long), + "num_obstacles": 1, + }, + } + env_data["agents"]["xyz"][0, 0] = torch.tensor([4.0, 1.0, 15.0]) + env_data["agents"]["heading"][0, 0] = 0.25 + session_state = SessionState( + session_uuid="session-1", + scene_id="clipgt-test-scene", + current_ts_us=0, + closed_loop_trajectories={}, + env_data=env_data, + handover_time_us=1_000_000, + ) + return session_state, env_data + + +def test_catk_apply_predictions_freezes_static_agent_when_predict_static_false() -> ( + None +): + session_state, env_data = _static_session_state() + actions = { + "agent_future_xyz": torch.tensor([[[100.0, 0.0, 0.0]]], dtype=torch.float32), + "agent_future_heading": torch.tensor([[0.0]], dtype=torch.float32), + "agent_future_valid_mask": torch.tensor([[True]]), + } + + _predictor(predict_static=False).apply_predictions_to_env( + session_state, + future_step_indices=[1], + actions=actions, + ) + + torch.testing.assert_close( + env_data["agents"]["xyz"][0, 1], + torch.tensor([4.0, 1.0, 15.0]), + ) + assert env_data["agents"]["heading"][0, 1].item() == pytest.approx(0.25) + assert bool(env_data["agents"]["valid_mask"][0, 1].item()) + + +def test_catk_apply_predictions_moves_static_agent_when_predict_static_true() -> None: + session_state, env_data = _static_session_state() + actions = { + "agent_future_xyz": torch.tensor([[[4.0, 1.0, 0.0]]], dtype=torch.float32), + "agent_future_heading": torch.tensor([[0.0]], dtype=torch.float32), + "agent_future_valid_mask": torch.tensor([[True]]), + } + + _predictor(predict_static=True).apply_predictions_to_env( + session_state, + future_step_indices=[1], + actions=actions, + ) + + torch.testing.assert_close( + env_data["agents"]["xyz"][0, 1], + torch.tensor([4.0, 1.0, 15.0]), + ) + assert env_data["agents"]["heading"][0, 1].item() == pytest.approx(0.0) diff --git a/src/trafficsim/tests/test_service.py b/src/trafficsim/tests/test_service.py new file mode 100644 index 00000000..34f89e29 --- /dev/null +++ b/src/trafficsim/tests/test_service.py @@ -0,0 +1,765 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation + +"""Black-box gRPC tests for the CATK traffic service.""" + +from __future__ import annotations + +import threading +from concurrent import futures +from contextlib import contextmanager +from typing import Any, Iterator + +import pytest +import torch +from alpasim_grpc.v0 import common_pb2, traffic_pb2, traffic_pb2_grpc +from alpasim_runtime.errors import UnknownSceneError +from alpasim_trafficsim.grpc.config import CatkLoaderConfig +from alpasim_trafficsim.grpc.servicer import TrafficServiceServicer +from alpasim_trafficsim.grpc.session.history import merge_trajectory +from alpasim_utils.geometry import trajectory_from_grpc + +import grpc + +DT_US = 100_000 +MIN_HISTORY = 16 +DEFAULT_SCENE_ID = "clipgt-test-scene" + + +class FakeSceneLoader: + """Returns scene data source handles for a fixed set of scene IDs.""" + + def __init__(self, scene_ids: list[str] | None = None) -> None: + self.scene_ids = set(scene_ids or [DEFAULT_SCENE_ID]) + + def get_data_source(self, scene_id: str) -> str: + if scene_id not in self.scene_ids: + raise UnknownSceneError(scene_id) + return scene_id + + +class FakeSceneAdapter: + """Returns a fixed base env_data for any accepted data source.""" + + def __init__(self, env_data: dict[str, Any]) -> None: + self._env_data = env_data + + def load(self, data_source: Any) -> dict[str, Any]: + del data_source + return self._env_data + + +class NoopPredictor: + """Never predicts; used to exercise the CATK-unavailable error path.""" + + def __init__(self, *, predict_static: bool = True) -> None: + self.predict_static = predict_static + + def run_inference( + self, env_data: dict[str, Any], *, predict_steps: int + ) -> dict[str, Any] | None: + del env_data, predict_steps + return None + + +class RecordingPredictor: + """Records each run_inference (predict_steps, curr_t); empty predictions.""" + + def __init__(self) -> None: + self.calls: list[tuple[int, int]] = [] + self.predict_static = True + + def run_inference( + self, env_data: dict[str, Any], *, predict_steps: int + ) -> dict[str, Any] | None: + self.calls.append((predict_steps, int(env_data["env"].get("curr_t", 0)))) + return { + "agent_future_xyz": torch.zeros((0, predict_steps, 3)), + "agent_future_heading": torch.zeros((0, predict_steps)), + "agent_future_valid_mask": torch.zeros( + (0, predict_steps), dtype=torch.bool + ), + } + + @staticmethod + def apply_predictions_to_env( + session_state: Any, *, future_step_indices: list[int], actions: dict[str, Any] + ) -> list[int]: + del actions + env_data = session_state.env_data + agents = env_data["agents"] + total_agents = int(agents["xyz"].shape[0]) + if total_agents == 0: + return future_step_indices + for step_idx in future_step_indices: + prev_step_idx = max(step_idx - 1, 0) + agents["xyz"][:, step_idx, :] = agents["xyz"][:, prev_step_idx, :] + agents["heading"][:, step_idx] = agents["heading"][:, prev_step_idx] + agents["valid_mask"][:, step_idx] = agents["valid_mask"][:, prev_step_idx] + return future_step_indices + + +class LinearPredictor(RecordingPredictor): + """Predicts agent x == future-timestamp-in-seconds (a unique value per step).""" + + def run_inference( + self, env_data: dict[str, Any], *, predict_steps: int + ) -> dict[str, Any] | None: + curr_t = int(env_data["env"].get("curr_t", 0)) + self.calls.append((predict_steps, curr_t)) + sample_start_t_us = int(env_data["env"]["sample_start_t_us"]) + current_ts_us = sample_start_t_us + curr_t * DT_US + future_ts_s = torch.tensor( + [ + (current_ts_us + ((offset + 1) * DT_US)) / 1_000_000.0 + for offset in range(predict_steps) + ], + dtype=torch.float32, + ) + xyz = torch.zeros((1, predict_steps, 3), dtype=torch.float32) + xyz[0, :, 0] = future_ts_s + return { + "agent_future_xyz": xyz, + "agent_future_heading": torch.zeros((1, predict_steps)), + "agent_future_valid_mask": torch.ones((1, predict_steps), dtype=torch.bool), + } + + @staticmethod + def apply_predictions_to_env( + session_state: Any, *, future_step_indices: list[int], actions: dict[str, Any] + ) -> None: + env_data = session_state.env_data + for offset, step_idx in enumerate(future_step_indices): + env_data["agents"]["xyz"][0, step_idx, :] = actions["agent_future_xyz"][ + 0, offset, : + ] + env_data["agents"]["heading"][0, step_idx] = actions[ + "agent_future_heading" + ][0, offset] + env_data["agents"]["valid_mask"][0, step_idx] = actions[ + "agent_future_valid_mask" + ][0, offset] + + +class BlockingPredictor(RecordingPredictor): + """Blocks the first inference until released so concurrency can be observed.""" + + def __init__(self) -> None: + super().__init__() + self._lock = threading.Lock() + self.first_started = threading.Event() + self.second_started = threading.Event() + self.release_first = threading.Event() + + def run_inference( + self, env_data: dict[str, Any], *, predict_steps: int + ) -> dict[str, Any] | None: + with self._lock: + self.calls.append((predict_steps, int(env_data["env"].get("curr_t", 0)))) + call_count = len(self.calls) + if call_count == 1: + self.first_started.set() + if not self.release_first.wait(timeout=2.0): + raise TimeoutError("timed out waiting to release first inference") + elif call_count == 2: + self.second_started.set() + return { + "agent_future_xyz": torch.zeros((0, predict_steps, 3)), + "agent_future_heading": torch.zeros((0, predict_steps)), + "agent_future_valid_mask": torch.zeros( + (0, predict_steps), dtype=torch.bool + ), + } + + +class ExplodingPredictor(NoopPredictor): + """Raises during inference to exercise the simulate INTERNAL error path.""" + + def run_inference( + self, env_data: dict[str, Any], *, predict_steps: int + ) -> dict[str, Any] | None: + raise RuntimeError("boom: model inference failed") + + +def _make_env_data(*, num_agents: int = 0, steps: int = 1) -> dict[str, Any]: + return { + "env": {}, + "metadata": { + "t0_us": 0, + "obstacle_class_name_2_id": {"car": 1, "others": 0}, + }, + "ego": { + "xyz": torch.zeros((steps, 3), dtype=torch.float32), + "heading": torch.zeros((steps,), dtype=torch.float32), + "lwh": torch.tensor([4.5, 2.0, 1.7], dtype=torch.float32), + }, + "agents": { + "xyz": torch.zeros((num_agents, steps, 3), dtype=torch.float32), + "heading": torch.zeros((num_agents, steps), dtype=torch.float32), + "valid_mask": torch.zeros((num_agents, steps), dtype=torch.bool), + "lwh": torch.ones((num_agents, 3), dtype=torch.float32), + "track_ids": torch.arange(1, num_agents + 1, dtype=torch.long), + "class_ids": torch.ones((num_agents,), dtype=torch.long), + "num_obstacles": num_agents, + }, + "map": {}, + } + + +def _pose( + timestamp_us: int, *, x: float | None = None, y: float = 0.0, z: float = 0.0 +) -> common_pb2.PoseAtTime: + x = float(timestamp_us) / 1_000_000.0 if x is None else float(x) + return common_pb2.PoseAtTime( + timestamp_us=timestamp_us, + pose=common_pb2.Pose( + vec=common_pb2.Vec3(x=x, y=y, z=z), + quat=common_pb2.Quat(w=1.0, x=0.0, y=0.0, z=0.0), + ), + ) + + +def _ego_trajectory(*, end_us: int = 3_000_000) -> common_pb2.Trajectory: + trajectory = common_pb2.Trajectory() + trajectory.poses.extend(_pose(ts) for ts in range(0, end_us + 1, DT_US)) + return trajectory + + +def _ego_object() -> traffic_pb2.ObjectTrajectory: + return traffic_pb2.ObjectTrajectory( + object_id="EGO", + aabb=common_pb2.AABB(size_x=4.5, size_y=2.0, size_z=1.7), + trajectory=_ego_trajectory(), + ) + + +def _moving_object(object_id: str = "moving-1") -> traffic_pb2.ObjectTrajectory: + return traffic_pb2.ObjectTrajectory( + object_id=object_id, + aabb=common_pb2.AABB(size_x=4.5, size_y=2.0, size_z=1.7), + trajectory=_ego_trajectory(), + is_static=False, + ) + + +def _static_object( + *, object_id: str = "static-1", x: float = 10.0, y: float = 1.0, z: float = 0.5 +) -> traffic_pb2.ObjectTrajectory: + return traffic_pb2.ObjectTrajectory( + object_id=object_id, + aabb=common_pb2.AABB(size_x=4.5, size_y=2.0, size_z=1.7), + trajectory=common_pb2.Trajectory(poses=[_pose(0, x=x, y=y, z=z)]), + is_static=True, + ) + + +def _ego_update( + timestamp_us: int, + *, + end_us: int | None = None, +) -> traffic_pb2.ObjectTrajectoryUpdate: + poses = [_pose(timestamp_us)] + if end_us is not None and end_us != timestamp_us: + poses.append(_pose(end_us)) + return traffic_pb2.ObjectTrajectoryUpdate( + object_id="EGO", + trajectory=common_pb2.Trajectory(poses=poses), + ) + + +def test_merge_trajectory_replaces_existing_future_segment() -> None: + existing = trajectory_from_grpc( + common_pb2.Trajectory( + poses=[ + _pose(100_000, x=1.0), + _pose(200_000, x=2.0), + _pose(300_000, x=3.0), + ] + ) + ) + incoming = trajectory_from_grpc( + common_pb2.Trajectory( + poses=[ + _pose(250_000, x=25.0), + _pose(350_000, x=35.0), + ] + ) + ) + trajectories = {"agent-1": existing} + + merge_trajectory(trajectories, object_id="agent-1", trajectory=incoming) + + merged = trajectories["agent-1"] + assert merged.timestamps_us.tolist() == [100_000, 200_000, 250_000, 350_000] + assert merged.positions[:, 0].tolist() == pytest.approx([1.0, 2.0, 25.0, 35.0]) + + +def _session_request( + *, + logged: list[traffic_pb2.ObjectTrajectory], + handover_time_us: int = 1_500_000, + session_uuid: str = "session-1", + scene_id: str = DEFAULT_SCENE_ID, +) -> traffic_pb2.TrafficSessionRequest: + return traffic_pb2.TrafficSessionRequest( + session_uuid=session_uuid, + scene_id=scene_id, + random_seed=7, + logged_object_trajectories=logged, + handover_time_us=handover_time_us, + ) + + +def _simulate_request( + time_query_us: int, + *, + session_uuid: str = "session-1", + ego_update_end_us: int | None = None, +) -> traffic_pb2.TrafficRequest: + return traffic_pb2.TrafficRequest( + session_uuid=session_uuid, + time_query_us=time_query_us, + object_trajectory_updates=[ + _ego_update(time_query_us, end_us=ego_update_end_us) + ], + ) + + +class RunningService: + """A started gRPC service: a real client stub plus the servicer for asserts.""" + + def __init__(self, stub: Any, servicer: TrafficServiceServicer) -> None: + self.stub = stub + self.servicer = servicer + + def session_state(self, session_uuid: str = "session-1") -> Any: + return self.servicer._sessions[session_uuid] + + +def _build_servicer( + *, + predictor: Any, + env_data: dict[str, Any] | None = None, + scene_loader: Any | None = None, +) -> TrafficServiceServicer: + """Construct a servicer with injected fakes, bypassing loader/model setup.""" + servicer = TrafficServiceServicer.__new__(TrafficServiceServicer) + servicer._server = None + servicer._lock = threading.Lock() + servicer._sessions = {} + servicer._service_version = "test-traffic-service" + servicer._time_step_s = DT_US / 1e6 + servicer._dt_us = DT_US + servicer._minimum_history_length = MIN_HISTORY + servicer._minimum_future_steps = 5 + servicer._loader_cfg = CatkLoaderConfig(usdz_folder="unused") + servicer._scene_loader = scene_loader or FakeSceneLoader() + servicer._scene_adapter = FakeSceneAdapter(env_data or _make_env_data()) + servicer._catk_predictor = predictor + return servicer + + +@contextmanager +def _serve( + predictor: Any | None = None, + *, + max_workers: int = 4, + scene_loader: Any | None = None, +) -> Iterator[RunningService]: + servicer = _build_servicer( + predictor=predictor or RecordingPredictor(), + scene_loader=scene_loader, + ) + server = grpc.server(futures.ThreadPoolExecutor(max_workers=max_workers)) + traffic_pb2_grpc.add_TrafficServiceServicer_to_server(servicer, server) + port = server.add_insecure_port("localhost:0") + server.start() + channel = grpc.insecure_channel(f"localhost:{port}") + try: + grpc.channel_ready_future(channel).result(timeout=5) + stub = traffic_pb2_grpc.TrafficServiceStub(channel) + yield RunningService(stub, servicer) + finally: + channel.close() + server.stop(0) + + +@pytest.fixture +def service() -> Iterator[RunningService]: + with _serve() as running: + yield running + + +def test_get_metadata_reports_service_version( + service: RunningService, +) -> None: + metadata = service.stub.get_metadata(common_pb2.Empty()) + assert metadata.version_id.version_id == "test-traffic-service" + assert metadata.minimum_history_length_us == MIN_HISTORY * DT_US + + +def test_get_available_scenes_reports_scene_ids(service: RunningService) -> None: + response = service.stub.get_available_scenes(common_pb2.Empty()) + assert DEFAULT_SCENE_ID in response.scene_ids + + +def test_start_then_close_session_round_trips(service: RunningService) -> None: + service.stub.start_session(_session_request(logged=[_ego_object()])) + assert "session-1" in service.servicer._sessions + + service.stub.close_session( + traffic_pb2.TrafficSessionCloseRequest(session_uuid="session-1") + ) + assert "session-1" not in service.servicer._sessions + + +def test_start_session_rejects_empty_uuid(service: RunningService) -> None: + with pytest.raises(grpc.RpcError) as exc: + service.stub.start_session( + _session_request(logged=[_ego_object()], session_uuid="") + ) + assert exc.value.code() == grpc.StatusCode.INVALID_ARGUMENT + + +def test_close_unknown_session_returns_not_found(service: RunningService) -> None: + with pytest.raises(grpc.RpcError) as exc: + service.stub.close_session( + traffic_pb2.TrafficSessionCloseRequest(session_uuid="ghost") + ) + assert exc.value.code() == grpc.StatusCode.NOT_FOUND + + +def test_simulate_unknown_session_returns_not_found(service: RunningService) -> None: + with pytest.raises(grpc.RpcError) as exc: + service.stub.simulate(_simulate_request(1_500_000, session_uuid="ghost")) + assert exc.value.code() == grpc.StatusCode.NOT_FOUND + + +def test_start_session_rejects_empty_scene_id(service: RunningService) -> None: + with pytest.raises(grpc.RpcError) as exc: + service.stub.start_session( + _session_request(logged=[_ego_object()], scene_id="") + ) + assert exc.value.code() == grpc.StatusCode.INVALID_ARGUMENT + + +def test_start_session_rejects_no_logged_trajectories( + service: RunningService, +) -> None: + with pytest.raises(grpc.RpcError) as exc: + service.stub.start_session(_session_request(logged=[])) + assert exc.value.code() == grpc.StatusCode.INVALID_ARGUMENT + + +def test_start_session_rejects_nonpositive_handover_time( + service: RunningService, +) -> None: + with pytest.raises(grpc.RpcError) as exc: + service.stub.start_session( + _session_request( + logged=[_ego_object()], + handover_time_us=0, + ) + ) + assert exc.value.code() == grpc.StatusCode.INVALID_ARGUMENT + assert "handover_time_us" in exc.value.details() + + +def test_start_session_requires_logged_ego_trajectory( + service: RunningService, +) -> None: + with pytest.raises(grpc.RpcError) as exc: + service.stub.start_session(_session_request(logged=[_moving_object()])) + assert exc.value.code() == grpc.StatusCode.INVALID_ARGUMENT + assert "EGO" in exc.value.details() + + +def test_start_session_rejects_empty_logged_ego_trajectory( + service: RunningService, +) -> None: + empty_ego = traffic_pb2.ObjectTrajectory( + object_id="EGO", + aabb=common_pb2.AABB(size_x=4.5, size_y=2.0, size_z=1.7), + trajectory=common_pb2.Trajectory(), + ) + + with pytest.raises(grpc.RpcError) as exc: + service.stub.start_session(_session_request(logged=[empty_ego])) + assert exc.value.code() == grpc.StatusCode.INVALID_ARGUMENT + assert "EGO" in exc.value.details() + + +def test_start_session_unknown_scene_returns_not_found() -> None: + with _serve(scene_loader=FakeSceneLoader(["clipgt-known-scene"])) as service: + with pytest.raises(grpc.RpcError) as exc: + service.stub.start_session( + _session_request( + logged=[_ego_object()], + scene_id="clipgt-does-not-exist", + ) + ) + assert exc.value.code() == grpc.StatusCode.NOT_FOUND + + +def test_simulate_inference_failure_returns_internal() -> None: + with _serve(ExplodingPredictor()) as service: + service.stub.start_session(_session_request(logged=[_ego_object()])) + with pytest.raises(grpc.RpcError) as exc: + service.stub.simulate(_simulate_request(2_000_000)) + assert exc.value.code() == grpc.StatusCode.INTERNAL + + +def test_simulate_missing_catk_predictions_returns_failed_precondition() -> None: + with _serve(NoopPredictor()) as service: + service.stub.start_session(_session_request(logged=[_ego_object()])) + with pytest.raises(grpc.RpcError) as exc: + service.stub.simulate(_simulate_request(2_000_000)) + assert exc.value.code() == grpc.StatusCode.FAILED_PRECONDITION + assert "CATK did not produce predictions" in exc.value.details() + + +def test_first_simulate_accepts_initial_history_time(service: RunningService) -> None: + service.stub.start_session(_session_request(logged=[_ego_object()])) + initial_ts_us = service.session_state().current_ts_us + assert initial_ts_us == 1_500_000 + + service.stub.simulate(_simulate_request(initial_ts_us)) + assert service.session_state().env_data["env"]["curr_t"] == 15 + + +def test_static_agent_with_one_pose_survives_model_simulate( + service: RunningService, +) -> None: + service.stub.start_session( + _session_request(logged=[_ego_object(), _static_object()]) + ) + response = service.stub.simulate(_simulate_request(1_600_000)) + + assert service.session_state().env_data["agents"]["valid_mask"][0, :17].all() + assert response.object_trajectory_updates[0].object_id == "static-1" + pose = response.object_trajectory_updates[0].trajectory.poses[0] + assert pose.timestamp_us == 1_600_000 + assert pose.pose.vec.x == pytest.approx(10.0) + assert pose.pose.vec.y == pytest.approx(1.0) + + +def test_dynamic_response_includes_available_prediction_horizon() -> None: + with _serve(LinearPredictor()) as service: + service.stub.start_session( + _session_request(logged=[_ego_object(), _moving_object()]) + ) + response = service.stub.simulate(_simulate_request(1_600_000)) + + assert len(response.object_trajectory_updates) == 1 + trajectory = response.object_trajectory_updates[0].trajectory + assert [pose.timestamp_us for pose in trajectory.poses] == [ + 1_600_000, + 1_700_000, + 1_800_000, + 1_900_000, + 2_000_000, + ] + assert [pose.pose.vec.x for pose in trajectory.poses] == pytest.approx( + [1.6, 1.7, 1.8, 1.9, 2.0] + ) + + +def test_logged_replay_does_not_extrapolate_past_track_end( + service: RunningService, +) -> None: + short_moving = traffic_pb2.ObjectTrajectory( + object_id="moving-1", + aabb=common_pb2.AABB(size_x=4.5, size_y=2.0, size_z=1.7), + trajectory=common_pb2.Trajectory( + poses=[_pose(0, x=10.0), _pose(100_000, x=11.0)] + ), + is_static=False, + ) + service.stub.start_session( + _session_request( + logged=[_ego_object(), short_moving], handover_time_us=2_000_000 + ) + ) + response = service.stub.simulate(_simulate_request(1_600_000)) + assert list(response.object_trajectory_updates) == [] + + +def test_logged_traffic_used_until_handover_then_model_runs() -> None: + predictor = RecordingPredictor() + with _serve(predictor) as service: + service.stub.start_session( + _session_request( + logged=[_ego_object(), _moving_object()], handover_time_us=2_000_000 + ) + ) + + before = service.stub.simulate(_simulate_request(1_600_000)) + assert predictor.calls == [] + assert before.object_trajectory_updates[0].object_id == "moving-1" + pose = before.object_trajectory_updates[0].trajectory.poses[0] + assert pose.pose.vec.x == pytest.approx(1.6) + + service.stub.simulate(_simulate_request(2_100_000)) + assert predictor.calls == [(5, MIN_HISTORY - 1)] + + +def test_off_grid_handover_uses_exact_anchor_for_catk() -> None: + predictor = RecordingPredictor() + with _serve(predictor) as service: + service.stub.start_session( + _session_request( + logged=[_ego_object(), _moving_object()], handover_time_us=2_050_000 + ) + ) + state = service.session_state() + + service.stub.simulate(_simulate_request(2_060_000)) + assert predictor.calls == [(5, MIN_HISTORY - 1)] + assert state.current_ts_us == 2_060_000 + assert state.env_data["env"]["sample_start_t_us"] == 550_000 + assert state.env_data["env"]["curr_t"] == MIN_HISTORY + + service.stub.simulate(_simulate_request(2_200_000)) + assert predictor.calls == [(5, MIN_HISTORY - 1), (5, MIN_HISTORY - 1)] + assert state.env_data["env"]["sample_start_t_us"] == 560_000 + assert state.env_data["env"]["curr_t"] == MIN_HISTORY + 1 + + +def test_off_grid_request_keeps_query_time_as_session_time( + service: RunningService, +) -> None: + service.stub.start_session(_session_request(logged=[_ego_object()])) + service.stub.simulate(_simulate_request(2_020_000, ego_update_end_us=2_100_000)) + + state = service.session_state() + assert state.env_data["env"]["curr_t"] == 21 + assert state.current_ts_us == 2_020_000 + assert state.env_data["ego"]["xyz"][21, 0].item() == pytest.approx(2.1) + + +def test_off_grid_request_accepts_single_ego_pose_at_query_time( + service: RunningService, +) -> None: + service.stub.start_session(_session_request(logged=[_ego_object()])) + service.stub.simulate(_simulate_request(2_020_000)) + + state = service.session_state() + assert state.env_data["env"]["curr_t"] == 21 + assert state.current_ts_us == 2_020_000 + assert state.env_data["ego"]["xyz"][20, 0].item() == pytest.approx(2.0) + assert state.env_data["ego"]["xyz"][21, 0].item() == pytest.approx(2.02) + + +def test_off_grid_response_is_interpolated_at_requested_time() -> None: + with _serve(LinearPredictor()) as service: + service.stub.start_session( + _session_request(logged=[_ego_object(), _moving_object()]) + ) + response = service.stub.simulate( + _simulate_request(2_020_000, ego_update_end_us=2_100_000) + ) + + assert len(response.object_trajectory_updates) == 1 + pose = response.object_trajectory_updates[0].trajectory.poses[0] + assert pose.timestamp_us == 2_020_000 + assert pose.pose.vec.x == pytest.approx(2.02) + + +def test_consecutive_off_grid_requests_resample_from_latest_query_time() -> None: + with _serve(LinearPredictor()) as service: + service.stub.start_session( + _session_request(logged=[_ego_object(), _moving_object()]) + ) + state = service.session_state() + + for query_ts_us in (2_020_000, 2_530_000, 3_040_000): + previous_current_ts_us = state.current_ts_us + assert previous_current_ts_us is not None + ego_update_end_us = ((query_ts_us + DT_US - 1) // DT_US) * DT_US + service.stub.simulate( + _simulate_request(query_ts_us, ego_update_end_us=ego_update_end_us) + ) + assert state.current_ts_us == query_ts_us + assert state.env_data["env"]["sample_start_t_us"] == ( + previous_current_ts_us - ((MIN_HISTORY - 1) * DT_US) + ) + + assert state.current_ts_us == 3_040_000 + + +def test_simulation_extends_current_sample_window() -> None: + with _serve(LinearPredictor()) as service: + service.stub.start_session( + _session_request(logged=[_ego_object(), _moving_object()]) + ) + state = service.session_state() + assert state.env_data["env"]["sample_start_t_us"] == 0 + + service.stub.simulate(_simulate_request(2_500_000)) + + assert state.env_data["env"]["curr_t"] == 25 + assert state.env_data["env"]["sample_start_t_us"] == 0 + assert state.env_data["agents"]["xyz"].shape[1] >= 26 + assert state.env_data["agents"]["xyz"][0, 25, 0].item() == pytest.approx(2.5) + + +def test_handover_resamples_logged_history_before_catk() -> None: + with _serve(LinearPredictor()) as service: + service.stub.start_session( + _session_request( + logged=[_ego_object(), _moving_object()], handover_time_us=2_000_000 + ) + ) + state = service.session_state() + assert state.env_data["env"]["sample_start_t_us"] == 0 + assert state.env_data["env"]["curr_t"] == 15 + assert state.env_data["agents"]["xyz"].shape[1] == MIN_HISTORY + + service.stub.simulate(_simulate_request(2_100_000)) + + assert state.env_data["env"]["sample_start_t_us"] == 500_000 + assert state.env_data["env"]["curr_t"] == MIN_HISTORY + assert state.env_data["agents"]["xyz"][0, 15, 0].item() == pytest.approx(2.0) + assert state.env_data["agents"]["xyz"][0, 16, 0].item() == pytest.approx(2.1) + + +def test_multi_token_horizon_uses_single_inference_call() -> None: + predictor = RecordingPredictor() + with _serve(predictor) as service: + service.stub.start_session(_session_request(logged=[_ego_object()])) + service.stub.simulate(_simulate_request(2_300_000)) + + assert len(predictor.calls) == 1 + assert predictor.calls[0] == (8, 15) + assert service.session_state().env_data["env"]["curr_t"] == 23 + + +def test_concurrent_simulate_for_same_session_is_serialized() -> None: + predictor = BlockingPredictor() + with _serve(predictor, max_workers=4) as service: + service.stub.start_session(_session_request(logged=[_ego_object()])) + + errors: list[BaseException] = [] + + def call(timestamp_us: int) -> None: + try: + service.stub.simulate(_simulate_request(timestamp_us)) + except BaseException as exc: # noqa: BLE001 + errors.append(exc) + + first = threading.Thread(target=call, args=(2_000_000,)) + second = threading.Thread(target=call, args=(2_100_000,)) + + first.start() + assert predictor.first_started.wait(timeout=2.0) + second.start() + assert not predictor.second_started.wait(timeout=0.3) + + predictor.release_first.set() + first.join(timeout=3.0) + second.join(timeout=3.0) + + assert not first.is_alive() and not second.is_alive() + assert errors == [] + assert predictor.calls == [(5, 15), (5, 15)] + assert service.session_state().current_ts_us == 2_100_000 diff --git a/src/trafficsim/tests/test_sparse_history_freeze.py b/src/trafficsim/tests/test_sparse_history_freeze.py new file mode 100644 index 00000000..0932c384 --- /dev/null +++ b/src/trafficsim/tests/test_sparse_history_freeze.py @@ -0,0 +1,180 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation + +"""Tests for sparse-history freezing in CATKTrafficPredictor.""" + +from __future__ import annotations + +from typing import Any + +import torch +from alpasim_trafficsim.grpc.catk_predictor import CATKTrafficPredictor +from alpasim_trafficsim.grpc.config import CatkConfig + + +def _predictor(min_valid_history_steps: int | None) -> CATKTrafficPredictor: + cfg = CatkConfig(predict_static=True) + if min_valid_history_steps is not None: + cfg.min_valid_history_steps = min_valid_history_steps + predictor = CATKTrafficPredictor.__new__(CATKTrafficPredictor) + predictor.cfg = cfg + predictor.predict_static = cfg.predict_static + predictor.history_window_steps = cfg.loader.num_history_steps + predictor.min_valid_history_steps = cfg.min_valid_history_steps + predictor.model = None + predictor._token_stride = 5 + return predictor + + +def _env_with_history(valid_history: list[bool]) -> dict: + """Single agent whose trailing history validity is ``valid_history``. + + The current step (prev_step_idx) is the last entry. Position/heading at the + current step are distinctive so we can detect a freeze (copy-forward). + """ + steps = len(valid_history) + xyz = torch.zeros((1, steps, 3), dtype=torch.float32) + heading = torch.zeros((1, steps), dtype=torch.float32) + valid = torch.tensor([valid_history], dtype=torch.bool) + xyz[0, -1] = torch.tensor([7.0, 8.0, 9.0]) + heading[0, -1] = 0.5 + return { + "env": {"agent_is_static": [False]}, + "agents": { + "xyz": xyz, + "heading": heading, + "valid_mask": valid, + "num_obstacles": 1, + }, + } + + +def _model_pred(num_steps: int) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + pred_xyz = torch.zeros((1, num_steps, 3), dtype=torch.float32) + pred_xyz[0, :, 0] = torch.arange(1, num_steps + 1, dtype=torch.float32) * 100.0 + pred_heading = torch.full((1, num_steps), 1.234, dtype=torch.float32) + pred_valid = torch.ones((1, num_steps), dtype=torch.bool) + return pred_xyz, pred_heading, pred_valid + + +def test_default_threshold_matches_packaged_config() -> None: + predictor = _predictor(min_valid_history_steps=None) + assert predictor.min_valid_history_steps == 5 + + +def test_single_valid_step_agent_frozen_at_default_threshold() -> None: + predictor = _predictor(min_valid_history_steps=None) + env = _env_with_history([False] * 15 + [True]) + pred = _model_pred(5) + out_xyz, out_heading, out_valid = predictor._postprocess_predictions( + env, + future_step_indices=[16, 17, 18, 19, 20], + pred_xyz=pred[0], + pred_heading=pred[1], + pred_valid=pred[2], + ) + for step in range(5): + torch.testing.assert_close(out_xyz[0, step], torch.tensor([7.0, 8.0, 9.0])) + assert out_heading[0, step].item() == 0.5 + assert bool(out_valid[0, step].item()) + + +def test_recently_spawned_agent_frozen_with_default() -> None: + predictor = _predictor(min_valid_history_steps=None) + env = _env_with_history([False] * 14 + [True, True]) + pred = _model_pred(5) + out_xyz, out_heading, out_valid = predictor._postprocess_predictions( + env, + future_step_indices=[16, 17, 18, 19, 20], + pred_xyz=pred[0], + pred_heading=pred[1], + pred_valid=pred[2], + ) + for step in range(5): + torch.testing.assert_close(out_xyz[0, step], torch.tensor([7.0, 8.0, 9.0])) + assert out_heading[0, step].item() == 0.5 + assert bool(out_valid[0, step].item()) + + +def test_explicit_threshold_one_keeps_single_valid_step_agent_predictable() -> None: + predictor = _predictor(min_valid_history_steps=1) + env = _env_with_history([False] * 15 + [True]) + pred = _model_pred(5) + out_xyz, out_heading, out_valid = predictor._postprocess_predictions( + env, + future_step_indices=[16, 17, 18, 19, 20], + pred_xyz=pred[0], + pred_heading=pred[1], + pred_valid=pred[2], + ) + torch.testing.assert_close(out_xyz[0, :, 0], pred[0][0, :, 0]) + torch.testing.assert_close(out_heading[0], pred[1][0]) + assert out_valid[0].all() + + +def test_threshold_zero_never_freezes_for_sparse_history() -> None: + predictor = _predictor(min_valid_history_steps=0) + env = _env_with_history([False] * 15 + [True]) + pred = _model_pred(5) + out_xyz, _, _ = predictor._postprocess_predictions( + env, + future_step_indices=[16, 17, 18, 19, 20], + pred_xyz=pred[0], + pred_heading=pred[1], + pred_valid=pred[2], + ) + torch.testing.assert_close(out_xyz[0, :, 0], pred[0][0, :, 0]) + + +class _EmptyMapModel: + model_predict_step_num = 0 + + def create_model_input(self, *args: Any, **kwargs: Any) -> dict[str, Any] | None: + del args, kwargs + return None + + def inference(self, input_data: dict[str, Any]) -> dict[str, Any]: + raise AssertionError("inference should not run when map input is empty") + + +def _env_for_inference() -> dict[str, Any]: + return { + "env": {"curr_t": 15, "agent_is_static": [False]}, + "ego": { + "xyz": torch.zeros((16, 3), dtype=torch.float32), + "heading": torch.zeros((16,), dtype=torch.float32), + }, + "agents": { + "xyz": torch.zeros((1, 16, 3), dtype=torch.float32), + "heading": torch.zeros((1, 16), dtype=torch.float32), + "valid_mask": torch.ones((1, 16), dtype=torch.bool), + "num_obstacles": 1, + }, + "map": {}, + } + + +def test_empty_filtered_map_returns_none_when_prediction_unavailable() -> None: + predictor = _predictor(min_valid_history_steps=None) + predictor.model = _EmptyMapModel() + + assert predictor.run_inference(_env_for_inference(), predict_steps=1) is None + + +def test_model_input_value_error_still_propagates() -> None: + class BrokenModel(_EmptyMapModel): + def create_model_input( + self, *args: Any, **kwargs: Any + ) -> dict[str, Any] | None: + del args, kwargs + raise ValueError("different CATK input failure") + + predictor = _predictor(min_valid_history_steps=None) + predictor.model = BrokenModel() + + try: + predictor.run_inference(_env_for_inference(), predict_steps=1) + except ValueError as exc: + assert str(exc) == "different CATK input failure" + else: + raise AssertionError("expected model-input ValueError to propagate") diff --git a/src/trafficsim/tests/test_usdz_data_folder.py b/src/trafficsim/tests/test_usdz_data_folder.py new file mode 100644 index 00000000..0241e67c --- /dev/null +++ b/src/trafficsim/tests/test_usdz_data_folder.py @@ -0,0 +1,59 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation + +"""Load USDZ archives from the repository ``data/`` folder.""" + +from pathlib import Path + +import pytest +from alpasim_runtime.scene_loader import ArtifactSceneProvider, SceneLoader +from alpasim_trafficsim.catk.scene_adapter import CATKSceneAdapter + + +def test_data_dir_exists(usdz_data_dir: Path) -> None: + assert ( + usdz_data_dir.is_dir() + ), f"Expected {usdz_data_dir} to exist (create it for local USDZ files)." + + +def test_runtime_scene_loader_discovers_scenes(usdz_data_dir: Path) -> None: + scene_loader = SceneLoader( + ArtifactSceneProvider.from_path( + usdz_data_dir, + smooth_trajectories=False, + ) + ) + assert len(scene_loader.scene_ids) >= 1 + + +@pytest.mark.integration +def test_catk_scene_adapter_loads_training_style_data( + usdz_data_dir: Path, usdz_from_data_dir: Path +) -> None: + del usdz_data_dir + scene_loader = SceneLoader( + ArtifactSceneProvider.from_path( + usdz_from_data_dir, + smooth_trajectories=False, + ) + ) + scene_id = next(iter(scene_loader.scene_ids)) + data_source = scene_loader.get_data_source(scene_id) + env_data = CATKSceneAdapter().load(data_source) + + assert env_data["map"], "Expected at least one decoded map layer" + assert env_data["env"]["curr_t"] == 15 + ego_steps = env_data["ego"]["xyz"].shape[0] + assert env_data["ego"]["xyz"].ndim == 2 + assert env_data["ego"]["xyz"].shape[1] == 3 + assert ego_steps > env_data["env"]["curr_t"] + assert env_data["ego"]["heading"].shape == (ego_steps,) + assert env_data["ego"]["lwh"].shape == (3,) + + agents = env_data["agents"] + assert agents["valid_mask"].ndim == 2 + assert agents["xyz"].ndim == 3 + assert agents["heading"].ndim == 2 + assert agents["valid_mask"].shape == agents["heading"].shape + assert agents["xyz"].shape[:2] == agents["valid_mask"].shape + assert agents["lwh"].shape[0] == agents["valid_mask"].shape[0] diff --git a/src/trafficsim/tests/test_usdz_loader_indexing.py b/src/trafficsim/tests/test_usdz_loader_indexing.py new file mode 100644 index 00000000..5786700a --- /dev/null +++ b/src/trafficsim/tests/test_usdz_loader_indexing.py @@ -0,0 +1,123 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation + + +import zipfile +from pathlib import Path +from types import SimpleNamespace + +import numpy as np +import pytest +import torch +import yaml +from alpasim_runtime.scene_loader import ArtifactSceneProvider, SceneLoader +from alpasim_trafficsim.catk.map_adapter import build_env_map_from_vector_map +from alpasim_trafficsim.catk.scene_adapter import CATKSceneAdapter +from alpasim_utils.artifact import Artifact +from trajdata.maps.vec_map_elements import MapElementType + + +def _metadata_yaml(scene_id: str) -> str: + return yaml.safe_dump( + { + "scene_id": scene_id, + "version_string": "test", + "training_date": "2026-01-01", + "dataset_hash": "test-hash", + "uuid": "test-uuid", + "is_resumable": False, + "sensors": {"camera_ids": [], "lidar_ids": []}, + "logger": {}, + "time_range": {"start": 0.0, "end": 1.0}, + } + ) + + +def test_runtime_scene_loader_indexes_nested_usdz_files(tmp_path) -> None: + nested_dir = tmp_path / "all-usdzs" + nested_dir.mkdir() + usdz_path = nested_dir / "scene.usdz" + with zipfile.ZipFile(usdz_path, "w") as zf: + zf.writestr("metadata.yaml", _metadata_yaml("clipgt-test-scene")) + + scene_loader = SceneLoader( + ArtifactSceneProvider.from_path( + tmp_path, + smooth_trajectories=False, + ) + ) + + assert "clipgt-test-scene" in scene_loader.scene_ids + + +def test_vector_map_adapter_resamples_before_catk_segmentation() -> None: + vector_map = SimpleNamespace( + elements={ + MapElementType.ROAD_LANE: { + "lane": SimpleNamespace( + center=SimpleNamespace( + xyz=np.array( + [ + [0.0, 0.0, 0.0], + [10.0, 0.0, 0.0], + ], + dtype=np.float32, + ) + ), + left_edge=None, + right_edge=None, + ) + } + } + ) + + without_resampling = build_env_map_from_vector_map( + vector_map, + ego_xyz=torch.tensor([0.0, 0.0, 0.0]), + ego_heading=0.0, + distance_x=0.0, + distance_y=0.0, + map_polyline_length_k=1, + map_resample_interval_m=None, + ) + with_resampling = build_env_map_from_vector_map( + vector_map, + ego_xyz=torch.tensor([0.0, 0.0, 0.0]), + ego_heading=0.0, + distance_x=0.0, + distance_y=0.0, + map_polyline_length_k=1, + map_resample_interval_m=1.0, + ) + + assert without_resampling["lane_centers"] is None + assert with_resampling["lane_centers"]["polylines"].shape == (5, 3, 3) + torch.testing.assert_close( + with_resampling["lane_centers"]["polylines"][0, :, 0], + torch.tensor([0.0, 1.0, 2.0]), + ) + + +@pytest.mark.integration +def test_catk_scene_adapter_runtime_path_reads_sample_artifact_with_vector_map() -> ( + None +): + sample = ( + Path(__file__).resolve().parents[3] + / "data/nre-artifacts/all-usdzs/5001cd19-e936-40d7-a42c-fa1fbf2bb2ba.usdz" + ) + if not sample.is_file(): + pytest.skip(f"Sample USDZ not found: {sample}") + + env_data = CATKSceneAdapter(motion_stepsize=0.1).load( + Artifact(str(sample), _smooth_trajectories=False) + ) + + assert type(env_data) is dict + assert env_data["metadata"]["map_source"] == "trajdata_vector_map" + assert env_data["agents"]["num_obstacles"] > 0 + for layer_name in ["lanelines", "road_boundaries", "waitlines", "lane_boundaries"]: + layer = env_data["map"][layer_name] + assert layer is not None + assert layer["polylines"].shape[1:] == (3, 3) + assert torch.isfinite(layer["polylines"]).all() diff --git a/src/utils/alpasim_utils/geometry.py b/src/utils/alpasim_utils/geometry.py index d760bddf..234bd145 100644 --- a/src/utils/alpasim_utils/geometry.py +++ b/src/utils/alpasim_utils/geometry.py @@ -12,6 +12,7 @@ from __future__ import annotations +import math from typing import NamedTuple try: @@ -32,6 +33,8 @@ "pose_to_grpc", "pose_from_grpc", "pose_to_grpc_at_time", + "quat_to_yaw", + "yaw_to_quat_components", # Polyline "Polyline", "ProjectionResult", @@ -85,6 +88,20 @@ def pose_to_grpc_at_time(pose: Pose, timestamp_us: int) -> grpc_types.PoseAtTime ) +def quat_to_yaw(quat: grpc_types.Quat) -> float: + """Return yaw from a gRPC quaternion.""" + return Pose.from_denormalized_quat( + np.zeros((3,), dtype=np.float32), + np.asarray([quat.x, quat.y, quat.z, quat.w], dtype=np.float32), + ).yaw() + + +def yaw_to_quat_components(yaw: float) -> tuple[float, float, float, float]: + """Return gRPC-order quaternion components ``(w, x, y, z)`` for yaw.""" + half_yaw = 0.5 * yaw + return (math.cos(half_yaw), 0.0, 0.0, math.sin(half_yaw)) + + # ============================================================================= # Polyline # ============================================================================= diff --git a/src/utils/alpasim_utils/telemetry/__init__.py b/src/utils/alpasim_utils/telemetry/__init__.py new file mode 100644 index 00000000..c4b06306 --- /dev/null +++ b/src/utils/alpasim_utils/telemetry/__init__.py @@ -0,0 +1,4 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation + +"""Shared telemetry helpers.""" diff --git a/src/utils/alpasim_utils/telemetry/alpasim-runtime-dashboard.json b/src/utils/alpasim_utils/telemetry/alpasim-runtime-dashboard.json new file mode 100644 index 00000000..14733109 --- /dev/null +++ b/src/utils/alpasim_utils/telemetry/alpasim-runtime-dashboard.json @@ -0,0 +1,1763 @@ +{ + "title": "AlpaSim Runtime", + "schemaVersion": 39, + "version": 1, + "refresh": "5s", + "time": { + "from": "now-30m", + "to": "now" + }, + "templating": { + "list": [ + { + "type": "query", + "name": "run_uuid", + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "query": "label_values(up{job=\"alpasim-runtime-worker\"}, run_uuid)", + "refresh": 1, + "multi": true, + "includeAll": true, + "allValue": ".*" + }, + { + "type": "query", + "name": "run_name", + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "query": "label_values(up{run_uuid=~\"$run_uuid\"}, run_name)", + "refresh": 1, + "multi": true, + "includeAll": true, + "allValue": ".*" + }, + { + "type": "query", + "name": "worker_id", + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "query": "label_values(alpasim_rpc_duration_seconds_count{run_uuid=~\"$run_uuid\"}, worker_id)", + "refresh": 1, + "multi": true, + "includeAll": true, + "allValue": ".*", + "hide": 2, + "current": { + "selected": true, + "text": "All", + "value": "$__all" + } + }, + { + "type": "query", + "name": "service", + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "query": "label_values(alpasim_rpc_duration_seconds_count{run_uuid=~\"$run_uuid\"}, service)", + "refresh": 1, + "multi": true, + "includeAll": true, + "allValue": ".*", + "hide": 2, + "current": { + "selected": true, + "text": "All", + "value": "$__all" + } + }, + { + "type": "query", + "name": "method", + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "query": "label_values(alpasim_rpc_duration_seconds_count{run_uuid=~\"$run_uuid\",service=~\"$service\"}, method)", + "refresh": 1, + "multi": true, + "includeAll": true, + "allValue": ".*", + "hide": 2, + "current": { + "selected": true, + "text": "All", + "value": "$__all" + } + }, + { + "type": "query", + "name": "tag", + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "query": "label_values(alpasim_rpc_duration_seconds_count{run_uuid=~\"$run_uuid\"}, tag)", + "refresh": 1, + "multi": true, + "includeAll": true, + "allValue": ".*", + "hide": 2, + "current": { + "selected": true, + "text": "All", + "value": "$__all" + } + }, + { + "type": "query", + "name": "node", + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "query": "label_values(up{run_uuid=~\"$run_uuid\"}, node)", + "refresh": 1, + "multi": true, + "includeAll": true, + "allValue": ".*", + "hide": 2, + "current": { + "selected": true, + "text": "All", + "value": "$__all" + } + }, + { + "type": "query", + "name": "groupname", + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "query": "label_values(namedprocess_namegroup_cpu_seconds_total{run_uuid=~\"$run_uuid\"}, groupname)", + "refresh": 1, + "multi": true, + "includeAll": true, + "allValue": ".*", + "hide": 2, + "current": { + "selected": true, + "text": "All", + "value": "$__all" + } + }, + { + "type": "query", + "name": "gpu", + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "query": "label_values(DCGM_FI_DEV_GPU_UTIL{run_uuid=~\"$run_uuid\"}, gpu)", + "refresh": 1, + "multi": true, + "includeAll": true, + "allValue": ".*", + "hide": 2, + "current": { + "selected": true, + "text": "All", + "value": "$__all" + } + } + ] + }, + "panels": [ + { + "type": "row", + "title": "Metrics plot summary", + "collapsed": false, + "gridPos": { + "x": 0, + "y": 0, + "w": 24, + "h": 1 + }, + "panels": [] + }, + { + "type": "timeseries", + "title": "RPC duration p95", + "gridPos": { + "x": 0, + "y": 1, + "w": 6, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "alpasim:rpc_duration_seconds:p95{run_uuid=~\"$run_uuid\",run_name=~\"$run_name\"}", + "legendFormat": "p95 {{method}}" + } + ] + }, + { + "type": "histogram", + "title": "RPC duration histogram", + "gridPos": { + "x": 6, + "y": 1, + "w": 6, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "(alpasim:rpc_duration_seconds:p95{run_uuid=~\"$run_uuid\",run_name=~\"$run_name\"}) > 0", + "legendFormat": "p95 {{method}}" + } + ], + "options": { + "bucketCount": 30, + "bucketSize": 0.05, + "combine": false, + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "fieldConfig": { + "defaults": { + "unit": "s" + }, + "overrides": [] + } + }, + { + "type": "timeseries", + "title": "RPC blocking p95", + "gridPos": { + "x": 6, + "y": 33, + "w": 6, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "alpasim:rpc_blocking_seconds:p95{run_uuid=~\"$run_uuid\",run_name=~\"$run_name\"}", + "legendFormat": "p95 {{method}}" + } + ] + }, + { + "type": "timeseries", + "title": "RPC request rate", + "gridPos": { + "x": 18, + "y": 1, + "w": 6, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "sum by (method) (rate(alpasim_rpc_duration_seconds_count{run_uuid=~\"$run_uuid\",run_name=~\"$run_name\",worker_id=~\"$worker_id\",method=~\"$method\",tag=~\"$tag\"}[1m]))", + "legendFormat": "{{method}}" + } + ] + }, + { + "type": "timeseries", + "title": "RPC queue depth latest", + "gridPos": { + "x": 0, + "y": 9, + "w": 6, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "alpasim:rpc_queue_depth_at_start_latest:max{run_uuid=~\"$run_uuid\",run_name=~\"$run_name\"}", + "legendFormat": "{{service}} max", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "alpasim:rpc_queue_depth_at_start_latest:min{run_uuid=~\"$run_uuid\",run_name=~\"$run_name\"}", + "legendFormat": "{{service}} min", + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "avg_over_time(alpasim:rpc_queue_depth_at_start_latest:max{run_uuid=~\"$run_uuid\",run_name=~\"$run_name\"}[10m])", + "legendFormat": "{{service}} max 10m avg", + "refId": "C" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "avg_over_time(alpasim:rpc_queue_depth_at_start_latest:min{run_uuid=~\"$run_uuid\",run_name=~\"$run_name\"}[10m])", + "legendFormat": "{{service}} min 10m avg", + "refId": "D" + } + ], + "fieldConfig": { + "defaults": {}, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "^controller max.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "blue", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "^controller min.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "light-blue", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "^driver max.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "^driver min.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "light-green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "^sensorsim max.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "^sensorsim min.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "light-orange", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "^physics max.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "purple", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "^physics min.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "light-purple", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "^traffic max.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "yellow", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "^traffic min.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "light-yellow", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "^video_model max.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "^video_model min.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "light-red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".* (max|min)$" + }, + "properties": [ + { + "id": "custom.lineWidth", + "value": 1 + }, + { + "id": "custom.lineStyle", + "value": { + "dash": [ + 10, + 10 + ], + "fill": "dash" + } + }, + { + "id": "custom.showPoints", + "value": "never" + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".* 10m avg$" + }, + "properties": [ + { + "id": "custom.lineWidth", + "value": 2 + }, + { + "id": "custom.showPoints", + "value": "never" + } + ] + } + ] + } + }, + { + "type": "histogram", + "title": "RPC queue depth histogram", + "gridPos": { + "x": 6, + "y": 9, + "w": 6, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "alpasim:rpc_queue_depth_at_start_latest:max{run_uuid=~\"$run_uuid\",run_name=~\"$run_name\"}", + "legendFormat": "{{service}} max" + } + ], + "options": { + "bucketCount": 30, + "combine": false, + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "fieldConfig": { + "defaults": { + "unit": "short" + }, + "overrides": [] + } + }, + { + "type": "timeseries", + "title": "Rollout duration p95", + "gridPos": { + "x": 12, + "y": 9, + "w": 6, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "alpasim:rollout_duration_seconds:p95{run_uuid=~\"$run_uuid\",run_name=~\"$run_name\"}", + "legendFormat": "p95" + } + ] + }, + { + "type": "timeseries", + "title": "Step duration p95", + "gridPos": { + "x": 18, + "y": 9, + "w": 6, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "alpasim:step_duration_seconds:p95{run_uuid=~\"$run_uuid\",run_name=~\"$run_name\"}", + "legendFormat": "p95" + } + ] + }, + { + "type": "timeseries", + "title": "Process CPU saturation", + "gridPos": { + "x": 0, + "y": 17, + "w": 6, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "alpasim:process_cpu_utilization_percent:max_by_group:rate30s{run_uuid=~\"$run_uuid\",run_name=~\"$run_name\"}", + "legendFormat": "max {{groupname}}", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "avg_over_time(alpasim:process_cpu_utilization_percent:max_by_group:rate30s{run_uuid=~\"$run_uuid\",run_name=~\"$run_name\"}[10m])", + "legendFormat": "10m avg {{groupname}}", + "refId": "B" + } + ], + "fieldConfig": { + "defaults": {}, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*controller.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "blue", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*driver.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*runtime.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*renderer.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*physics.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "purple", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*trafficsim.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "yellow", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byFrameRefID", + "options": "B" + }, + "properties": [ + { + "id": "custom.lineWidth", + "value": 2 + }, + { + "id": "custom.showPoints", + "value": "never" + } + ] + }, + { + "matcher": { + "id": "byFrameRefID", + "options": "A" + }, + "properties": [ + { + "id": "custom.lineWidth", + "value": 1 + }, + { + "id": "custom.lineStyle", + "value": { + "dash": [ + 10, + 10 + ], + "fill": "dash" + } + }, + { + "id": "custom.showPoints", + "value": "never" + } + ] + } + ] + } + }, + { + "type": "histogram", + "title": "Process CPU saturation histogram", + "gridPos": { + "x": 6, + "y": 17, + "w": 6, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "alpasim:process_cpu_utilization_percent:max_by_group:rate30s{run_uuid=~\"$run_uuid\",run_name=~\"$run_name\"}", + "legendFormat": "max {{groupname}}", + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "alpasim:process_cpu_utilization_percent:per_process:rate30s{run_uuid=~\"$run_uuid\",run_name=~\"$run_name\"} and on (run_uuid, run_name, groupname, pid, port, instance) topk by (groupname) (3, max_over_time(alpasim:process_cpu_utilization_percent:per_process:rate30s{run_uuid=~\"$run_uuid\",run_name=~\"$run_name\"}[$__range] @ end()))", + "legendFormat": "top {{groupname}} {{port}} pid={{pid}}", + "refId": "A" + } + ], + "options": { + "bucketCount": 30, + "combine": false, + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "fieldConfig": { + "defaults": { + "unit": "percent" + }, + "overrides": [] + } + }, + { + "type": "timeseries", + "title": "GPU utilization", + "gridPos": { + "x": 0, + "y": 25, + "w": 6, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "alpasim:gpu_utilization_percent:avg{run_uuid=~\"$run_uuid\",run_name=~\"$run_name\"}", + "legendFormat": "gpu {{gpu}} %", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "avg_over_time(alpasim:gpu_utilization_percent:avg{run_uuid=~\"$run_uuid\",run_name=~\"$run_name\"}[10m])", + "legendFormat": "gpu {{gpu}} % 10m avg", + "refId": "B" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "^gpu 0 .*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "blue", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "^gpu 1 .*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "^gpu 2 .*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "^gpu 3 .*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "^gpu 4 .*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "purple", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "^gpu 5 .*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "yellow", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "^gpu 6 .*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "light-blue", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "^gpu 7 .*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "semi-dark-green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byFrameRefID", + "options": "A" + }, + "properties": [ + { + "id": "custom.lineWidth", + "value": 1 + }, + { + "id": "custom.lineStyle", + "value": { + "dash": [ + 10, + 10 + ], + "fill": "dash" + } + }, + { + "id": "custom.showPoints", + "value": "never" + } + ] + }, + { + "matcher": { + "id": "byFrameRefID", + "options": "B" + }, + "properties": [ + { + "id": "custom.lineWidth", + "value": 2 + }, + { + "id": "custom.showPoints", + "value": "never" + } + ] + } + ] + } + }, + { + "type": "histogram", + "title": "GPU utilization histogram", + "gridPos": { + "x": 6, + "y": 25, + "w": 6, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "alpasim:gpu_utilization_percent:avg{run_uuid=~\"$run_uuid\",run_name=~\"$run_name\"}", + "legendFormat": "gpu {{gpu}} %" + } + ], + "options": { + "bucketCount": 30, + "combine": false, + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "fieldConfig": { + "defaults": { + "unit": "percent" + }, + "overrides": [] + } + }, + { + "type": "timeseries", + "title": "GPU memory", + "gridPos": { + "x": 12, + "y": 25, + "w": 6, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "alpasim:gpu_memory_gb:avg{run_uuid=~\"$run_uuid\",run_name=~\"$run_name\"}", + "legendFormat": "gpu {{gpu}} used GiB", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "max(max_over_time(alpasim:gpu_memory_total_gb:avg{run_uuid=~\"$run_uuid\",run_name=~\"$run_name\"}[$__range] @ end()))", + "legendFormat": "GPU capacity GiB", + "refId": "B" + } + ], + "fieldConfig": { + "defaults": {}, + "overrides": [ + { + "matcher": { + "id": "byFrameRefID", + "options": "B" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "white", + "mode": "fixed" + } + }, + { + "id": "custom.lineStyle", + "value": { + "dash": [ + 10, + 10 + ], + "fill": "dash" + } + }, + { + "id": "custom.showPoints", + "value": "never" + } + ] + } + ] + } + }, + { + "type": "histogram", + "title": "GPU memory histogram", + "gridPos": { + "x": 18, + "y": 25, + "w": 6, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "alpasim:gpu_memory_gb:avg{run_uuid=~\"$run_uuid\",run_name=~\"$run_name\"}", + "legendFormat": "gpu {{gpu}} used GB" + } + ], + "options": { + "bucketCount": 30, + "combine": false, + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "fieldConfig": { + "defaults": { + "unit": "suffix: GB" + }, + "overrides": [] + } + }, + { + "type": "timeseries", + "title": "Host CPU and memory", + "gridPos": { + "x": 12, + "y": 17, + "w": 6, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "100 - avg(rate(node_cpu_seconds_total{run_uuid=~\"$run_uuid\",run_name=~\"$run_name\",mode=\"idle\"}[1m])) * 100", + "legendFormat": "cpu %" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "100 * (1 - node_memory_MemAvailable_bytes{run_uuid=~\"$run_uuid\",run_name=~\"$run_name\"} / node_memory_MemTotal_bytes{run_uuid=~\"$run_uuid\",run_name=~\"$run_name\"})", + "legendFormat": "memory %" + } + ] + }, + { + "type": "timeseries", + "title": "Event loop idle fraction", + "gridPos": { + "x": 0, + "y": 33, + "w": 6, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "alpasim:event_loop_idle_fraction:ratio{run_uuid=~\"$run_uuid\",run_name=~\"$run_name\"}", + "legendFormat": "idle fraction" + } + ] + }, + { + "type": "timeseries", + "title": "Seconds per rollout", + "gridPos": { + "x": 12, + "y": 1, + "w": 6, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "alpasim:simulation_seconds_per_rollout:rate5m{run_uuid=~\"$run_uuid\",run_name=~\"$run_name\"}", + "legendFormat": "5m" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "alpasim:simulation_seconds_per_rollout:avg{run_uuid=~\"$run_uuid\",run_name=~\"$run_name\"}", + "legendFormat": "full run" + } + ] + }, + { + "type": "heatmap", + "title": "NRE render RPC duration distribution", + "gridPos": { + "x": 12, + "y": 33, + "w": 6, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "alpasim:nre_render_rpc_duration_seconds_bucket:rate1m{run_uuid=~\"$run_uuid\",run_name=~\"$run_name\"}", + "format": "heatmap", + "legendFormat": "{{le}}" + } + ], + "options": { + "calculate": false, + "cellGap": 1, + "color": { + "mode": "scheme", + "scheme": "Spectral", + "steps": 64 + }, + "yAxis": { + "axisPlacement": "left", + "reverse": false, + "unit": "s" + } + } + }, + { + "type": "heatmap", + "title": "Controller run RPC duration distribution", + "gridPos": { + "x": 18, + "y": 33, + "w": 6, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "alpasim:rpc_duration_seconds_bucket:rate1m{run_uuid=~\"$run_uuid\",run_name=~\"$run_name\",method=\"run_controller_and_vehicle\"}", + "format": "heatmap", + "legendFormat": "{{le}}" + } + ], + "options": { + "calculate": false, + "cellGap": 1, + "color": { + "mode": "scheme", + "scheme": "Spectral", + "steps": 64 + }, + "yAxis": { + "axisPlacement": "left", + "reverse": false, + "unit": "s" + } + } + }, + { + "type": "heatmap", + "title": "Driver drive RPC duration distribution", + "gridPos": { + "x": 0, + "y": 41, + "w": 6, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "alpasim:rpc_duration_seconds_bucket:rate1m{run_uuid=~\"$run_uuid\",run_name=~\"$run_name\",method=\"drive\"}", + "format": "heatmap", + "legendFormat": "{{le}}" + } + ], + "options": { + "calculate": false, + "cellGap": 1, + "color": { + "mode": "scheme", + "scheme": "Spectral", + "steps": 64 + }, + "yAxis": { + "axisPlacement": "left", + "reverse": false, + "unit": "s" + } + } + }, + { + "type": "heatmap", + "title": "Driver image observation RPC duration distribution", + "gridPos": { + "x": 6, + "y": 41, + "w": 6, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "alpasim:rpc_duration_seconds_bucket:rate1m{run_uuid=~\"$run_uuid\",run_name=~\"$run_name\",method=\"submit_image_observation\"}", + "format": "heatmap", + "legendFormat": "{{le}}" + } + ], + "options": { + "calculate": false, + "cellGap": 1, + "color": { + "mode": "scheme", + "scheme": "Spectral", + "steps": 64 + }, + "yAxis": { + "axisPlacement": "left", + "reverse": false, + "unit": "s" + } + } + }, + { + "type": "heatmap", + "title": "Driver egomotion observation RPC duration distribution", + "gridPos": { + "x": 12, + "y": 41, + "w": 6, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "alpasim:rpc_duration_seconds_bucket:rate1m{run_uuid=~\"$run_uuid\",run_name=~\"$run_name\",method=\"submit_egomotion_observation\"}", + "format": "heatmap", + "legendFormat": "{{le}}" + } + ], + "options": { + "calculate": false, + "cellGap": 1, + "color": { + "mode": "scheme", + "scheme": "Spectral", + "steps": 64 + }, + "yAxis": { + "axisPlacement": "left", + "reverse": false, + "unit": "s" + } + } + }, + { + "type": "heatmap", + "title": "Driver route RPC duration distribution", + "gridPos": { + "x": 18, + "y": 41, + "w": 6, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "alpasim:rpc_duration_seconds_bucket:rate1m{run_uuid=~\"$run_uuid\",run_name=~\"$run_name\",method=\"submit_route\"}", + "format": "heatmap", + "legendFormat": "{{le}}" + } + ], + "options": { + "calculate": false, + "cellGap": 1, + "color": { + "mode": "scheme", + "scheme": "Spectral", + "steps": 64 + }, + "yAxis": { + "axisPlacement": "left", + "reverse": false, + "unit": "s" + } + } + }, + { + "type": "heatmap", + "title": "Physics ground intersection RPC duration distribution", + "gridPos": { + "x": 0, + "y": 49, + "w": 6, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "alpasim:rpc_duration_seconds_bucket:rate1m{run_uuid=~\"$run_uuid\",run_name=~\"$run_name\",method=\"ground_intersection\"}", + "format": "heatmap", + "legendFormat": "{{le}}" + } + ], + "options": { + "calculate": false, + "cellGap": 1, + "color": { + "mode": "scheme", + "scheme": "Spectral", + "steps": 64 + }, + "yAxis": { + "axisPlacement": "left", + "reverse": false, + "unit": "s" + } + } + }, + { + "type": "row", + "title": "Detailed telemetry", + "collapsed": false, + "gridPos": { + "x": 0, + "y": 57, + "w": 24, + "h": 1 + }, + "panels": [] + }, + { + "type": "timeseries", + "title": "Rollout duration p95 by worker", + "gridPos": { + "x": 12, + "y": 58, + "w": 12, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "alpasim:rollout_duration_seconds:p95_by_worker{run_uuid=~\"$run_uuid\",run_name=~\"$run_name\",worker_id=~\"$worker_id\"}", + "legendFormat": "worker {{worker_id}}" + } + ] + }, + { + "type": "timeseries", + "title": "Process top memory", + "gridPos": { + "x": 0, + "y": 66, + "w": 12, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "topk(6, sum by (groupname) (namedprocess_namegroup_memory_bytes{run_uuid=~\"$run_uuid\",run_name=~\"$run_name\",memtype=\"resident\"}) / 1024 / 1024)", + "legendFormat": "{{groupname}} MiB" + } + ] + }, + { + "type": "row", + "title": "External resource telemetry", + "collapsed": false, + "gridPos": { + "x": 0, + "y": 74, + "w": 24, + "h": 1 + }, + "panels": [] + }, + { + "type": "timeseries", + "title": "Node CPU utilization", + "gridPos": { + "x": 0, + "y": 75, + "w": 8, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "100 * (1 - avg by (node) (rate(node_cpu_seconds_total{run_uuid=~\"$run_uuid\",run_name=~\"$run_name\",node=~\"$node\",mode=\"idle\"}[1m])))", + "legendFormat": "{{node}} cpu %" + } + ] + }, + { + "type": "timeseries", + "title": "Host memory available", + "gridPos": { + "x": 8, + "y": 75, + "w": 8, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "node_memory_MemAvailable_bytes{run_uuid=~\"$run_uuid\",run_name=~\"$run_name\",node=~\"$node\"} / 1024 / 1024 / 1024", + "legendFormat": "{{node}} available GiB" + } + ] + }, + { + "type": "timeseries", + "title": "Process resident memory", + "gridPos": { + "x": 18, + "y": 17, + "w": 6, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "sum by (node, groupname) (namedprocess_namegroup_memory_bytes{run_uuid=~\"$run_uuid\",run_name=~\"$run_name\",node=~\"$node\",groupname=~\"$groupname\",memtype=\"resident\"}) / 1024 / 1024 / 1024", + "legendFormat": "{{node}} {{groupname}} GiB" + } + ] + }, + { + "type": "timeseries", + "title": "GPU utilization by node", + "gridPos": { + "x": 0, + "y": 83, + "w": 8, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "DCGM_FI_DEV_GPU_UTIL{run_uuid=~\"$run_uuid\",run_name=~\"$run_name\",node=~\"$node\",gpu=~\"$gpu\"}", + "legendFormat": "{{node}} gpu {{gpu}} %" + } + ] + }, + { + "type": "timeseries", + "title": "GPU memory pressure", + "gridPos": { + "x": 8, + "y": 83, + "w": 8, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "alpasim:gpu_memory_pressure_percent:avg{run_uuid=~\"$run_uuid\",run_name=~\"$run_name\",node=~\"$node\",gpu=~\"$gpu\"}", + "legendFormat": "{{node}} gpu {{gpu}} memory %" + } + ] + }, + { + "type": "timeseries", + "title": "Process exporter scrape duration", + "gridPos": { + "x": 16, + "y": 83, + "w": 8, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "alpasim_slurm_process_exporter_scrape_duration_seconds{run_uuid=~\"$run_uuid\",run_name=~\"$run_name\",node=~\"$node\"}", + "legendFormat": "{{node}} {{instance}} seconds" + } + ] + }, + { + "type": "timeseries", + "title": "GC time spent rate", + "gridPos": { + "x": 0, + "y": 91, + "w": 8, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "sum(rate(alpasim_gc_total_duration_seconds{run_uuid=~\"$run_uuid\",run_name=~\"$run_name\",worker_id=~\"$worker_id\"}[1m]))", + "legendFormat": "gc seconds/s" + } + ] + }, + { + "type": "stat", + "title": "GC worst pause so far", + "gridPos": { + "x": 8, + "y": 91, + "w": 8, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "max(alpasim_gc_max_duration_seconds{run_uuid=~\"$run_uuid\",run_name=~\"$run_name\",worker_id=~\"$worker_id\"})", + "legendFormat": "seconds" + } + ] + }, + { + "type": "timeseries", + "title": "GC collection rate", + "gridPos": { + "x": 16, + "y": 91, + "w": 8, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "sum(rate(alpasim_gc_collection_count_total{run_uuid=~\"$run_uuid\",run_name=~\"$run_name\",worker_id=~\"$worker_id\"}[1m]))", + "legendFormat": "collections/s" + } + ] + } + ] +} diff --git a/src/utils/alpasim_utils/telemetry/metrics_plot_recording_rules.yml b/src/utils/alpasim_utils/telemetry/metrics_plot_recording_rules.yml new file mode 100644 index 00000000..a3e35313 --- /dev/null +++ b/src/utils/alpasim_utils/telemetry/metrics_plot_recording_rules.yml @@ -0,0 +1,154 @@ +groups: + - name: alpasim_metrics_plot + rules: + - record: alpasim:rpc_duration_seconds_bucket:sum + expr: >- + sum by (run_uuid, run_name, method, le) + (alpasim_rpc_duration_seconds_bucket{method=~"run_controller_and_vehicle|drive|submit_egomotion_observation|submit_image_observation|submit_route|ground_intersection|render_rgb"}) + - record: alpasim:rpc_duration_seconds_bucket:rate1m + expr: >- + sum by (run_uuid, run_name, method, le) + (rate(alpasim_rpc_duration_seconds_bucket{method=~"run_controller_and_vehicle|drive|submit_egomotion_observation|submit_image_observation|submit_route|ground_intersection|render_rgb"}[1m])) + - record: alpasim:rpc_blocking_seconds_bucket:sum + expr: >- + sum by (run_uuid, run_name, method, le) + (alpasim_rpc_blocking_seconds_bucket{method=~"run_controller_and_vehicle|drive|submit_egomotion_observation|submit_image_observation|submit_route|ground_intersection|render_rgb"}) + - record: alpasim:rpc_queue_depth_at_start_latest:max + expr: >- + max by (run_uuid, run_name, service) + (alpasim_rpc_queue_depth_at_start_latest) + - record: alpasim:rpc_queue_depth_at_start_latest:min + expr: >- + min by (run_uuid, run_name, service) + (alpasim_rpc_queue_depth_at_start_latest) + - record: alpasim:rollout_duration_seconds_bucket:sum + expr: >- + sum by (run_uuid, run_name, le) + (alpasim_rollout_duration_seconds_bucket) + - record: alpasim:step_duration_seconds_bucket:sum + expr: >- + sum by (run_uuid, run_name, le) + (alpasim_step_duration_seconds_bucket) + - record: alpasim:driver_drive_rpc_duration_seconds_bucket:sum + expr: >- + sum by (run_uuid, run_name, le) + (alpasim_rpc_duration_seconds_bucket{service="driver",method="drive",tag="default"}) + - record: alpasim:driver_drive_rpc_duration_seconds_bucket:rate1m + expr: >- + sum by (run_uuid, run_name, le) + (rate(alpasim_rpc_duration_seconds_bucket{service="driver",method="drive",tag="default"}[1m])) + - record: alpasim:nre_render_rpc_duration_seconds_bucket:sum + expr: >- + sum by (run_uuid, run_name, le) + (alpasim_rpc_duration_seconds_bucket{service="sensorsim",method=~"render_rgb|batch_render_rgb|render_aggregated",tag="default"}) + - record: alpasim:nre_render_rpc_duration_seconds_bucket:rate1m + expr: >- + sum by (run_uuid, run_name, le) + (rate(alpasim_rpc_duration_seconds_bucket{service="sensorsim",method=~"render_rgb|batch_render_rgb|render_aggregated",tag="default"}[1m])) + - record: alpasim:rpc_duration_seconds:p95 + expr: >- + histogram_quantile(0.95, + sum by (run_uuid, run_name, method, le) + (rate(alpasim:rpc_duration_seconds_bucket:sum[1m]))) + - record: alpasim:rpc_blocking_seconds:p95 + expr: >- + histogram_quantile(0.95, + sum by (run_uuid, run_name, method, le) + (rate(alpasim:rpc_blocking_seconds_bucket:sum[1m]))) + - record: alpasim:rollout_duration_seconds:p95 + expr: >- + histogram_quantile(0.95, + sum by (run_uuid, run_name, le) + (rate(alpasim:rollout_duration_seconds_bucket:sum[1m]))) + - record: alpasim:rollout_duration_seconds:p95_by_worker + expr: >- + histogram_quantile(0.95, + sum by (run_uuid, run_name, worker_id, le) + (rate(alpasim_rollout_duration_seconds_bucket[1m]))) + - record: alpasim:step_duration_seconds:p95 + expr: >- + histogram_quantile(0.95, + sum by (run_uuid, run_name, le) + (rate(alpasim:step_duration_seconds_bucket:sum[1m]))) + - record: alpasim:driver_drive_rpc_duration_seconds:p95 + expr: >- + histogram_quantile(0.95, + sum by (run_uuid, run_name, le) + (alpasim:driver_drive_rpc_duration_seconds_bucket:rate1m)) + - record: alpasim:nre_render_rpc_duration_seconds:p95 + expr: >- + histogram_quantile(0.95, + sum by (run_uuid, run_name, le) + (alpasim:nre_render_rpc_duration_seconds_bucket:rate1m)) + - record: alpasim:event_loop_idle_fraction:ratio + expr: >- + sum by (run_uuid, run_name) + (alpasim_event_loop_idle_seconds_total) / + sum by (run_uuid, run_name) + (alpasim_event_loop_idle_seconds_total + + alpasim_event_loop_poll_seconds_total + + alpasim_event_loop_work_seconds_total) + - record: alpasim:simulation_rollouts_completed:sum + expr: >- + sum by (run_uuid, run_name) + (alpasim_simulation_rollouts_completed_total) + - record: alpasim:simulation_seconds_per_rollout:avg + expr: >- + ( + max by (run_uuid, run_name) + (alpasim_simulation_elapsed_seconds) / + sum by (run_uuid, run_name) + (alpasim_simulation_rollouts_completed_total) + ) + and on (run_uuid, run_name) + ( + sum by (run_uuid, run_name) + (alpasim_simulation_rollouts_completed_total) > 0 + ) + - record: alpasim:simulation_seconds_per_rollout:rate5m + expr: >- + ( + max by (run_uuid, run_name) + (increase(alpasim_simulation_elapsed_seconds[5m])) / + sum by (run_uuid, run_name) + (increase(alpasim_simulation_rollouts_completed_total[5m])) + ) + and on (run_uuid, run_name) + ( + sum by (run_uuid, run_name) + (increase(alpasim_simulation_rollouts_completed_total[5m])) > 0 + ) + - record: alpasim:process_cpu_utilization_percent:rate30s + expr: >- + 100 * sum by (run_uuid, run_name, groupname) + (rate(namedprocess_namegroup_cpu_seconds_total[30s])) + - record: alpasim:process_cpu_utilization_percent:per_process:rate30s + expr: >- + 100 * rate(alpasim_process_cpu_seconds_total[30s]) + - record: alpasim:process_cpu_utilization_percent:max_by_group:rate30s + expr: >- + max by (run_uuid, run_name, groupname) + (alpasim:process_cpu_utilization_percent:per_process:rate30s) + - record: alpasim:gpu_utilization_percent:avg + expr: >- + avg by (run_uuid, run_name, gpu) + (DCGM_FI_DEV_GPU_UTIL) + - record: alpasim:gpu_memory_gb:avg + expr: >- + avg by (run_uuid, run_name, gpu) + (DCGM_FI_DEV_FB_USED) / 1024 + - record: alpasim:gpu_memory_total_gb:avg + expr: >- + avg by (run_uuid, run_name, gpu) + (DCGM_FI_DEV_FB_TOTAL) / 1024 + - record: alpasim:gpu_memory_pressure_percent:avg + expr: >- + 100 * + sum by (run_uuid, run_name, node, gpu) + (DCGM_FI_DEV_FB_USED) / + ( + sum by (run_uuid, run_name, node, gpu) + (DCGM_FI_DEV_FB_USED) + + sum by (run_uuid, run_name, node, gpu) + (DCGM_FI_DEV_FB_FREE) + ) diff --git a/src/utils/pyproject.toml b/src/utils/pyproject.toml index cc9658b6..19bbbba3 100644 --- a/src/utils/pyproject.toml +++ b/src/utils/pyproject.toml @@ -43,3 +43,6 @@ utils_rs = {workspace = true} [tool.setuptools.packages.find] where = ["."] include = ["alpasim_utils*"] + +[tool.setuptools.package-data] +"alpasim_utils.telemetry" = ["*.json", "*.yml"] diff --git a/src/utils/tests/test_polyline.py b/src/utils/tests/test_polyline.py index 2d7ffe18..bf7ae780 100644 --- a/src/utils/tests/test_polyline.py +++ b/src/utils/tests/test_polyline.py @@ -211,6 +211,52 @@ def test_polyline_resample_from_point_cases() -> None: assert np.allclose(off_path.waypoints[-1], [20, 0, 0]) +def test_polyline_resample_by_spacing() -> None: + polyline_obj = Polyline(points=np.array([[0.0, 0.0, 0.0], [10.0, 0.0, 0.0]])) + + resampled = polyline_obj.resample_by_spacing(3.0) + + assert np.allclose( + resampled.waypoints, + np.array( + [ + [0.0, 0.0, 0.0], + [3.0, 0.0, 0.0], + [6.0, 0.0, 0.0], + [9.0, 0.0, 0.0], + [10.0, 0.0, 0.0], + ], + dtype=np.float32, + ), + ) + + +def test_polyline_resample_by_spacing_can_skip_endpoint() -> None: + polyline_obj = Polyline(points=np.array([[0.0, 0.0, 0.0], [10.0, 0.0, 0.0]])) + + resampled = polyline_obj.resample_by_spacing(3.0, include_endpoint=False) + + assert np.allclose( + resampled.waypoints, + np.array( + [ + [0.0, 0.0, 0.0], + [3.0, 0.0, 0.0], + [6.0, 0.0, 0.0], + [9.0, 0.0, 0.0], + ], + dtype=np.float32, + ), + ) + + +def test_polyline_resample_by_spacing_rejects_non_positive_spacing() -> None: + polyline_obj = Polyline(points=np.array([[0.0, 0.0, 0.0], [10.0, 0.0, 0.0]])) + + with pytest.raises(ValueError, match="spacing must be positive"): + polyline_obj.resample_by_spacing(0.0) + + def test_polyline_get_cumulative_distances_from_point() -> None: polyline_obj = Polyline( points=np.array( diff --git a/src/utils_rs/src/polyline.rs b/src/utils_rs/src/polyline.rs index 4c9939be..55ef74d2 100644 --- a/src/utils_rs/src/polyline.rs +++ b/src/utils_rs/src/polyline.rs @@ -271,6 +271,89 @@ impl Polyline { (remaining_points, projection) } + + /// Interpolate positions at sorted arc-length distances. + fn positions_at_distances_impl(&self, distances: &[f32]) -> PyResult> { + if self.is_empty() { + return Err(PyErr::new::( + "Cannot interpolate along an empty polyline", + )); + } + + let arc_lengths = self.arc_lengths_impl(); + + // Handle duplicate arc lengths (zero-length segments) + let mut unique_lengths = Vec::new(); + let mut unique_indices = Vec::new(); + for (i, &len) in arc_lengths.iter().enumerate() { + if unique_lengths.is_empty() || len != *unique_lengths.last().unwrap() { + unique_lengths.push(len); + unique_indices.push(i); + } + } + + if unique_lengths.is_empty() { + return Err(PyErr::new::( + "Cannot interpolate along an empty polyline", + )); + } + + // Validate distances are within range + if !distances.is_empty() { + let min_dist = distances.iter().cloned().fold(f32::INFINITY, f32::min); + let max_dist = distances.iter().cloned().fold(f32::NEG_INFINITY, f32::max); + + if min_dist < unique_lengths[0] || max_dist > unique_lengths[unique_lengths.len() - 1] { + return Err(PyErr::new::( + "Requested distances must lie within the polyline arc length range", + )); + } + } + + if unique_lengths.len() == 1 { + let point = self.get_point(unique_indices[0]); + let mut result = Vec::with_capacity(distances.len() * self.dimension); + for _ in distances { + result.extend(point); + } + return Ok(result); + } + + // Interpolate each dimension + let mut result = Vec::with_capacity(distances.len() * self.dimension); + + for &dist in distances { + // Binary search for segment + let seg_idx = match unique_lengths.binary_search_by(|&len| { + len.partial_cmp(&dist).unwrap_or(std::cmp::Ordering::Equal) + }) { + Ok(i) => i.min(unique_lengths.len() - 2), + Err(i) => (i.saturating_sub(1)).min(unique_lengths.len() - 2), + }; + + let next_idx = seg_idx + 1; + + let len0 = unique_lengths[seg_idx]; + let len1 = unique_lengths[next_idx]; + let idx0 = unique_indices[seg_idx]; + let idx1 = unique_indices[next_idx]; + + let alpha = if (len1 - len0).abs() < 1e-10 { + 0.0 + } else { + (dist - len0) / (len1 - len0) + }; + + let p0 = self.get_point(idx0); + let p1 = self.get_point(idx1); + + for d in 0..self.dimension { + result.push(p0[d] + alpha * (p1[d] - p0[d])); + } + } + + Ok(result) + } } #[pymethods] @@ -513,91 +596,68 @@ impl Polyline { distances: PyObject, ) -> PyResult>> { let distances_slice = extract_array1_f32(py, &distances, "distances")?; + let result = self.positions_at_distances_impl(&distances_slice)?; - if self.is_empty() { - return Err(PyErr::new::( - "Cannot interpolate along an empty polyline", - )); - } - - let arc_lengths = self.arc_lengths_impl(); + PyArray2::from_vec2( + py, + &result + .chunks(self.dimension) + .map(|c| c.to_vec()) + .collect::>(), + ) + .map_err(|e| PyErr::new::(format!("{}", e))) + } - // Handle duplicate arc lengths (zero-length segments) - let mut unique_lengths = Vec::new(); - let mut unique_indices = Vec::new(); - for (i, &len) in arc_lengths.iter().enumerate() { - if unique_lengths.is_empty() || len != *unique_lengths.last().unwrap() { - unique_lengths.push(len); - unique_indices.push(i); - } + /// Uniformly resample the full polyline by arc-length spacing. + /// + /// Args: + /// spacing: Distance between samples. + /// include_endpoint: Append the final waypoint if it does not land on the spacing grid. + /// + /// Returns: + /// A new Polyline with resampled points. + #[pyo3(signature = (spacing, include_endpoint=true))] + fn resample_by_spacing(&self, spacing: f64, include_endpoint: bool) -> PyResult { + let spacing = spacing as f32; + if !spacing.is_finite() || spacing <= 0.0 { + return Err(PyErr::new::(format!( + "spacing must be positive and finite, got {spacing}", + ))); } - if unique_lengths.is_empty() { - return Err(PyErr::new::( - "Cannot interpolate along an empty polyline", - )); + if self.is_empty() || self.len() == 1 { + return Ok(self.clone()); } - // Validate distances are within range - if !distances_slice.is_empty() { - let min_dist = distances_slice - .iter() - .cloned() - .fold(f32::INFINITY, f32::min); - let max_dist = distances_slice - .iter() - .cloned() - .fold(f32::NEG_INFINITY, f32::max); - - if min_dist < unique_lengths[0] || max_dist > unique_lengths[unique_lengths.len() - 1] { - return Err(PyErr::new::( - "Requested distances must lie within the polyline arc length range", - )); - } + let total_length = self.total_length(); + if total_length <= 1e-6 { + return Ok(Self { + points: self.get_point(0).to_vec(), + dimension: self.dimension, + }); } - // Interpolate each dimension - let mut result = Vec::with_capacity(distances_slice.len() * self.dimension); - - for &dist in &distances_slice { - // Binary search for segment - let seg_idx = match unique_lengths.binary_search_by(|&len| { - len.partial_cmp(&dist).unwrap_or(std::cmp::Ordering::Equal) - }) { - Ok(i) => i.min(unique_lengths.len() - 1), - Err(i) => (i.saturating_sub(1)).min(unique_lengths.len() - 2), - }; - - let seg_idx = seg_idx.min(unique_lengths.len() - 2); - let next_idx = seg_idx + 1; - - let len0 = unique_lengths[seg_idx]; - let len1 = unique_lengths[next_idx]; - let idx0 = unique_indices[seg_idx]; - let idx1 = unique_indices[next_idx]; - - let alpha = if (len1 - len0).abs() < 1e-10 { - 0.0 - } else { - (dist - len0) / (len1 - len0) - }; - - let p0 = self.get_point(idx0); - let p1 = self.get_point(idx1); - - for d in 0..self.dimension { - result.push(p0[d] + alpha * (p1[d] - p0[d])); + let mut distances = Vec::new(); + let mut distance = 0.0_f32; + while distance < total_length { + distances.push(distance); + distance += spacing; + } + if include_endpoint { + let should_append_endpoint = distances + .last() + .map(|last| (total_length - *last).abs() > 1e-5) + .unwrap_or(true); + if should_append_endpoint { + distances.push(total_length); } } - PyArray2::from_vec2( - py, - &result - .chunks(self.dimension) - .map(|c| c.to_vec()) - .collect::>(), - ) - .map_err(|e| PyErr::new::(format!("{}", e))) + let points = self.positions_at_distances_impl(&distances)?; + Ok(Self { + points, + dimension: self.dimension, + }) } /// Return the polyline remainder after projecting a point. diff --git a/src/utils_rs/utils_rs.pyi b/src/utils_rs/utils_rs.pyi index b21f266c..3a53ee68 100644 --- a/src/utils_rs/utils_rs.pyi +++ b/src/utils_rs/utils_rs.pyi @@ -532,6 +532,21 @@ class Polyline: """ ... + def resample_by_spacing( + self, + spacing: float, + include_endpoint: bool = True, + ) -> Polyline: + """ + Uniformly resample the full polyline by arc-length spacing. + + :param spacing: Distance between samples. + :param include_endpoint: Append the final waypoint if it does not land + on the spacing grid. + :return: A new Polyline with resampled points. + """ + ... + def remaining_from_point( self, point: NDArray[np.floating], diff --git a/src/wizard/alpasim_wizard/configuration.py b/src/wizard/alpasim_wizard/configuration.py index 12f9fc87..93a5e0fd 100644 --- a/src/wizard/alpasim_wizard/configuration.py +++ b/src/wizard/alpasim_wizard/configuration.py @@ -15,10 +15,11 @@ from alpasim_wizard.context import WizardContext from alpasim_wizard.schema import AlpasimConfig, RunMode +from alpasim_wizard.telemetry.prometheus import generate_prometheus_configs from omegaconf import OmegaConf from .services import ContainerDefinition, ContainerSet -from .utils import save_loadable_wizard_config, write_yaml +from .utils import read_yaml, save_loadable_wizard_config, write_yaml logger = logging.getLogger(__name__) @@ -36,11 +37,9 @@ class ConfigurationManager: def __init__(self, log_dir: str): self.log_dir = Path(log_dir) - self.generated_configs: Dict[str, Path] = {} + self._central_file_sd_path: Path | None = None - def generate_all( - self, container_set: ContainerSet, context: WizardContext - ) -> Dict[str, Path]: + def generate_all(self, container_set: ContainerSet, context: WizardContext) -> None: """Generate all required configurations. Args: @@ -50,10 +49,13 @@ def generate_all( logger.info("Generating all configurations...") cfg = context.cfg - artifact_list = context.get_artifacts() + artifact_list = context.artifact_list + + run_metadata = self._load_or_create_run_metadata(cfg) + self._write_config("run_metadata.yaml", run_metadata) # Generate each configuration - self._generate_runtime_config(cfg, artifact_list) + self._generate_runtime_config(cfg, artifact_list, context) # Get sim containers from service_manager for network config sim_containers = container_set.sim @@ -62,18 +64,24 @@ def generate_all( self._generate_trafficsim_config(cfg) self._generate_eval_config(cfg) - self._generate_run_metadata(cfg) + self._central_file_sd_path = generate_prometheus_configs( + self.log_dir, + run_metadata, + context, + ) self._generate_driver_config(cfg) self._generate_controller_config(cfg) # Save wizard config self._save_wizard_config(cfg) - logger.info(f"Generated {len(self.generated_configs)} configuration files") - return self.generated_configs + logger.info("Generated configuration files") def _generate_runtime_config( - self, cfg: Any, artifact_list: List[Any] + self, + cfg: Any, + artifact_list: List[Any], + context: WizardContext, ) -> str | None: """Generate runtime configuration.""" runtime_config = OmegaConf.to_container(cfg.runtime, resolve=True) @@ -96,7 +104,18 @@ def _generate_runtime_config( # Write simulation params directly (was: fan out per scene) simulation_config = runtime_config.pop("simulation_config", {}) + telemetry_ports = context.telemetry_ports + prometheus_host = ( + "localhost" + if cfg.wizard.debug_flags.use_localhost + or cfg.wizard.run_method.name == "SLURM" + else "prometheus-0" + ) runtime_config["simulation_config"] = simulation_config + runtime_config["prometheus"] = { + "worker_ports": list(telemetry_ports.workers), + "url": f"http://{prometheus_host}:{telemetry_ports.prometheus}", + } # Write flat scene list runtime_config["scenes"] = [{"scene_id": s.scene_id} for s in artifact_list] @@ -206,13 +225,13 @@ def _generate_runtime_server_config( if cfg.wizard.run_mode != RunMode.SERVER: return - runtime_containers = container_set.runtime or [] - if not runtime_containers: + runtime_container = container_set.runtime + if runtime_container is None: raise ValueError( "Server mode requires `runtime` in wizard.run_sim_services" ) - runtime_addresses = runtime_containers[0].get_all_addresses() + runtime_addresses = runtime_container.get_all_addresses() if not runtime_addresses: raise ValueError("Runtime server mode requires a runtime address") @@ -271,8 +290,17 @@ def _generate_controller_config(self, cfg: Any) -> None: self._write_config("controller-config.yaml", controller_config) logger.debug("Generated controller config") - def _generate_run_metadata(self, cfg: Any) -> None: - """Generate run metadata.""" + def _load_or_create_run_metadata(self, cfg: Any) -> dict[str, Any]: + """Load existing run metadata, or create it for a new run. + + Makes sure e.g. run_uuid stays the same across restarts of the same run. + """ + metadata_path = self.log_dir / "run_metadata.yaml" + if metadata_path.exists(): + run_metadata = read_yaml(str(metadata_path)) + run_metadata.setdefault("run_uuid", str(uuid.uuid4())) + return run_metadata + run_uuid = uuid.uuid4() run_name = ( cfg.wizard.run_name @@ -300,9 +328,7 @@ def _generate_run_metadata(self, cfg: Any) -> None: else None ), } - - self._write_config("run_metadata.yaml", run_metadata) - logger.debug("Generated run metadata") + return run_metadata def _save_wizard_config(self, cfg: Any) -> None: """Save the complete wizard configuration.""" @@ -320,10 +346,23 @@ def _save_wizard_config(self, cfg: Any) -> None: def _write_config(self, filename: str, data: Dict) -> Path: """Write configuration to file.""" filepath = self.log_dir / filename + filepath.parent.mkdir(parents=True, exist_ok=True) write_yaml(data, str(filepath)) - self.generated_configs[filename] = filepath return filepath + def cleanup_central_file_sd(self) -> None: + """Remove this run's central file-SD publication after deployment exits.""" + if self._central_file_sd_path is None: + return + try: + self._central_file_sd_path.unlink(missing_ok=True) + except OSError as exc: + logger.warning( + "Failed to remove Prometheus file-SD %s: %s", + self._central_file_sd_path, + exc, + ) + def _remove_none_values(self, d: Any) -> Any: """Recursively remove all keys with None values from the dictionary.""" if not isinstance(d, dict): diff --git a/src/wizard/alpasim_wizard/context.py b/src/wizard/alpasim_wizard/context.py index 8ef9825b..9160f7d6 100644 --- a/src/wizard/alpasim_wizard/context.py +++ b/src/wizard/alpasim_wizard/context.py @@ -121,12 +121,39 @@ def setup_directories(cfg: AlpasimConfig) -> None: logger.debug(f"Creating log directory at path: {log_dir}") # Create subdirectories - for subdir in ("rollouts", "telemetry", "txt-logs", "controller"): + for subdir in ( + "rollouts", + "txt-logs", + "controller", + "prometheus", + ): subdir_path = log_dir / subdir subdir_path.mkdir(parents=True, exist_ok=True, mode=0o777) os.chmod(subdir_path, 0o777) +@dataclass +class TelemetryPorts: + """Ports allocated together for telemetry services.""" + + workers: tuple[int, ...] + prometheus: int + node_exporter: int + process_exporter: int + dcgm_exporter: int + + def prometheus_service_ports(self) -> dict[str, int]: + return { + "prometheus": self.prometheus, + "node_exporter": self.node_exporter, + "process_exporter": self.process_exporter, + "dcgm_exporter": self.dcgm_exporter, + } + + def runtime_worker_ports(self) -> dict[str, int]: + return {f"runtime_worker_{idx}": port for idx, port in enumerate(self.workers)} + + @dataclass class WizardContext: """Unified context for all wizard operations. @@ -137,19 +164,12 @@ class WizardContext: cfg: AlpasimConfig port_assigner: Iterator[int] + telemetry_ports: TelemetryPorts # Expensive operations (only loaded when needed for actual execution) artifact_list: list[SceneIdAndUuid] = field(default_factory=list) num_gpus: int = 0 - def get_num_gpus(self) -> int: - """Get GPU count with fallback to 0.""" - return self.num_gpus if self.num_gpus is not None else 0 - - def get_artifacts(self) -> list[SceneIdAndUuid]: - """Get artifacts with fallback to empty list.""" - return self.artifact_list if self.artifact_list is not None else [] - @property def all_services_to_run(self) -> list[str]: """Get all services that should be run.""" @@ -161,13 +181,31 @@ def create(cfg: AlpasimConfig) -> WizardContext: # Always set these basic attributes artifact_list = fetch_artifacts(cfg) + port_assigner = create_port_assigner(cfg.wizard.baseport) + nr_workers = int(cfg.runtime.nr_workers) + # We preallocate them so they are consistent across call sites + telemetry_ports = TelemetryPorts( + workers=tuple(next(port_assigner) for _ in range(nr_workers)), + prometheus=next(port_assigner), + node_exporter=next(port_assigner), + process_exporter=next(port_assigner), + dcgm_exporter=next(port_assigner), + ) context = WizardContext( cfg=cfg, - port_assigner=create_port_assigner(cfg.wizard.baseport), + port_assigner=port_assigner, + telemetry_ports=telemetry_ports, artifact_list=artifact_list, num_gpus=detect_gpus(), ) - + logger.info( + "Prometheus UI: http://localhost:%d", + telemetry_ports.prometheus, + ) + logger.info( + "Prometheus file-SD dir: %s", + cfg.wizard.prometheus.file_sd_dir, + ) setup_directories(cfg) return context diff --git a/src/wizard/alpasim_wizard/deployment/docker_compose.py b/src/wizard/alpasim_wizard/deployment/docker_compose.py index 20ffeeea..b287018b 100644 --- a/src/wizard/alpasim_wizard/deployment/docker_compose.py +++ b/src/wizard/alpasim_wizard/deployment/docker_compose.py @@ -105,6 +105,7 @@ def _to_docker_compose_service( Docker Compose service configuration dict """ ret: dict[str, Any] = {} + service_config = container.service_config use_host_network = self.context.cfg.wizard.debug_flags.use_localhost if use_host_network: # Tell Docker to use the host network @@ -112,16 +113,16 @@ def _to_docker_compose_service( else: ret["networks"] = ["microservices_network"] ret["volumes"] = [v.to_str() for v in container.volumes] - ret["pull_policy"] = container.service_config.pull_policy - ret["image"] = container.service_config.image + ret["pull_policy"] = service_config.pull_policy + ret["image"] = service_config.image repo_root = str(find_repo_root(__file__)) - if not container.service_config.external_image: + if not service_config.external_image: build_config: dict[str, Any] = { "context": repo_root, "dockerfile": "Dockerfile", - "tags": [container.service_config.image], + "tags": [service_config.image], } if _netrc_secret_file() is not None: build_config["secrets"] = ["netrc"] @@ -134,7 +135,7 @@ def _to_docker_compose_service( # We use \$ to declare fields that should not be interpreted by # 'our' OmegaConf parser, but by downstream parsers in the service. # Furhtermore, for docker-compose, we need to escape $ as $$ - command = command.replace(r"\$", "$$") + command = command.replace("$", "$$") # Set permissive umask so files written to bind-mounted volumes # are accessible by the host user (containers run as root). command = "umask 0000\n" + command @@ -152,8 +153,14 @@ def _to_docker_compose_service( container.name == "runtime" and self.context.cfg.wizard.run_mode == RunMode.SERVER ) + ports: list[str] = [] + if not use_host_network and container.published_ports: + ports.extend( + f"{port}:{port}" for port in container.published_ports.values() + ) if addresses and (use_host_network or publish_runtime_server_port): - ports = [f"{addr.port}:{addr.port}" for addr in addresses] + ports.extend(f"{addr.port}:{addr.port}" for addr in addresses) + if ports: ret["ports"] = ports if container.gpu is not None: @@ -170,6 +177,20 @@ def _to_docker_compose_service( } } } + elif container.name == "prometheus" and self.context.num_gpus > 0: + ret["deploy"] = { + "resources": { + "reservations": { + "devices": [ + { + "driver": "nvidia", + "count": "all", + "capabilities": ["gpu"], + } + ] + } + } + } return ret def generate_docker_compose_yaml(self, container_set: Any) -> str: @@ -192,26 +213,13 @@ def generate_docker_compose_yaml(self, container_set: Any) -> str: service = self._to_docker_compose_service(c) services[c.uuid] = service - # Add runtime services last - for c in container_set.runtime or []: - service = self._to_docker_compose_service(c) - # Runtime needs host PID namespace for process monitoring - service["pid"] = "host" - # Runtime needs access to all GPUs for telemetry/resource monitoring - service["deploy"] = { - "resources": { - "reservations": { - "devices": [ - { - "driver": "nvidia", - "count": "all", - "capabilities": ["gpu"], - } - ] - } - } - } - services[c.uuid] = service + service = self._to_docker_compose_service(container_set.prometheus) + services[container_set.prometheus.uuid] = service + + # Add runtime service last + if container_set.runtime is not None: + service = self._to_docker_compose_service(container_set.runtime) + services[container_set.runtime.uuid] = service # Create compose structure with ordered services compose: dict[str, Any] = { diff --git a/src/wizard/alpasim_wizard/deployment/slurm.py b/src/wizard/alpasim_wizard/deployment/slurm.py index 2baaaf30..e735883d 100644 --- a/src/wizard/alpasim_wizard/deployment/slurm.py +++ b/src/wizard/alpasim_wizard/deployment/slurm.py @@ -7,6 +7,7 @@ import logging import os +import shlex import socket import time from pathlib import Path @@ -37,10 +38,12 @@ def deploy_all_services(self) -> None: """Deploy simulation services (including runtime) on SLURM.""" logger.info("Running simulation services") containers_to_start_last = ( - self.container_set.runtime if self.container_set.runtime else [] + [self.container_set.runtime] if self.container_set.runtime else [] ) + containers = list(self.container_set.sim) + containers.append(self.container_set.prometheus) self.deploy( - containers=self.container_set.sim, + containers=containers, containers_to_start_last=containers_to_start_last, ) @@ -190,16 +193,15 @@ def _to_slurm_run( if container.gpu is not None else "" ) - # Separate environment variables: # - 'VAR=value' format to export in bash. The value will be logged, not secure for secrets. # - 'VAR' format pass-through from host. The value will not be logged, secure for secrets. env_export_set = [] # VAR=value format - env_passthrough_set = [] # VAR only format + env_passthrough_set = ["SLURM_JOB_ID"] # VAR only format for e in container.environments or []: if "=" in e: env_export_set.append(e) - else: + elif e not in env_passthrough_set: env_passthrough_set.append(e) # Construct environment variable arguments @@ -209,10 +211,9 @@ def _to_slurm_run( if env_export_set else "" ) - # Use --export for pass-through variables from host environment - s_env_passthrough = ( - f"--export={','.join(env_passthrough_set)} " if env_passthrough_set else "" - ) + # Slurm exports the submit environment by default. Keep container steps + # isolated, while preserving the job id needed for Slurm-scoped telemetry. + s_env_export_arg = f"--export={','.join(env_passthrough_set)} " s_mnt = ",".join([v.to_str() for v in container.volumes]) @@ -238,11 +239,12 @@ def _to_slurm_run( if not container.service_config.remap_root: cmd += " --no-container-remap-root " - escaped_command = container.command.replace("$$", r"\$") + expanded_command = container.command.replace("$$", "$") + bash_command = shlex.quote(f"{s_gpu}{s_env_exports}{expanded_command}") if mode in (RunMode.ONESHOT, RunMode.SERVER): - cmd += f"--output={s_log} --error={s_log} {s_env_passthrough}" - cmd += f'bash -c "{s_gpu}{s_env_exports}{escaped_command}"' + cmd += f"--output={s_log} --error={s_log} {s_env_export_arg}" + cmd += f"bash -c {bash_command}" else: raise ValueError(f"Unknown run mode: {mode}") return cmd @@ -294,7 +296,8 @@ def wait_for_containers( if timeout is not None and s_waited > timeout: if raise_on_timeout: raise TimeoutError( - f"Address {service_instance.address} of {container.name} " + f"Address {service_instance.address} of " + f"{container.name} " "did not open in time" ) else: diff --git a/src/wizard/alpasim_wizard/schema.py b/src/wizard/alpasim_wizard/schema.py index 1c9f1a74..49111676 100644 --- a/src/wizard/alpasim_wizard/schema.py +++ b/src/wizard/alpasim_wizard/schema.py @@ -85,6 +85,12 @@ class RunMode(Enum): SERVER = "server" +@dataclass +class WizardPrometheusConfig: + scrape_interval: str = "5s" + file_sd_dir: str | None = None + + @dataclass class WizardConfig: # Name of the run, used to identify the run in the databases. @@ -94,6 +100,7 @@ class WizardConfig: # Global log level for all alpasim services (DEBUG, INFO, WARNING, ERROR) log_level: str = "INFO" + prometheus: WizardPrometheusConfig = field(default_factory=WizardPrometheusConfig) description: str | None = None # TODO(mwatson): is this redundant to run_name? submitter: str | None = None @@ -145,16 +152,24 @@ class ServicesConfig: trafficsim: ServiceConfig | None = MISSING controller: ServiceConfig | None = MISSING runtime: RuntimeServiceConfig = MISSING + prometheus: ContainerConfig = MISSING @dataclass -class ServiceConfig: +class ContainerConfig: volumes: list[str] = field(default_factory=list) image: str = MISSING # Images that don't correspond to a service in the repo. # No Dockerfile path is added to the docker-compose.yaml. external_image: bool = False pull_policy: str = "missing" + environments: list[str] = field(default_factory=list) + workdir: str | None = None + remap_root: bool = False + + +@dataclass +class ServiceConfig(ContainerConfig): command: list[str] = MISSING # Number of service replicas to run per container. # If gpus is None or empty, creates a single container with this many replicas. @@ -162,10 +177,6 @@ class ServiceConfig: replicas_per_container: int = MISSING gpus: list[int] | None = MISSING - environments: list[str] = field(default_factory=list) - workdir: str | None = None - remap_root: bool = False - @dataclass class RuntimeServiceConfig(ServiceConfig): diff --git a/src/wizard/alpasim_wizard/services.py b/src/wizard/alpasim_wizard/services.py index 57b936c5..c8bed682 100644 --- a/src/wizard/alpasim_wizard/services.py +++ b/src/wizard/alpasim_wizard/services.py @@ -10,14 +10,26 @@ import os import socket from dataclasses import dataclass, field +from importlib.resources import files as resource_files from typing import Any, Iterator, List, Literal from .context import WizardContext -from .schema import RunMode, ServiceConfig +from .schema import ContainerConfig, RunMode, ServiceConfig logger = logging.getLogger(__name__) +def resolve_prometheus_command(context: WizardContext) -> str: + command = ( + resource_files("alpasim_wizard") + .joinpath("telemetry/resources/prometheus_sidecar.sh") + .read_text(encoding="utf-8") + ) + for name, port in context.telemetry_ports.prometheus_service_ports().items(): + command = command.replace(f"{{prometheus_ports.{name}}}", str(port)) + return command + + @dataclass class Address: host: str @@ -35,17 +47,23 @@ def is_open(self) -> bool: class VolumeMount: host: str container: str + options: str | None = None @staticmethod def from_str(string: str) -> VolumeMount: try: - host, container = string.split(":") + parts = string.split(":", maxsplit=2) + host, container = parts[:2] + options = parts[2] if len(parts) == 3 else None except ValueError as e: raise ValueError(f"Failed to convert {string=} to VolumeMount") from e - return VolumeMount(host, container) + return VolumeMount(host, container, options) def to_str(self) -> str: - return f"{self.host}:{self.container}" + mount = f"{self.host}:{self.container}" + if self.options is not None: + return f"{mount}:{self.options}" + return mount def host_exists(self) -> bool: return os.path.exists(self.host) @@ -72,21 +90,22 @@ def port(self) -> int | None: return self.address.port if self.address is not None else None @property - def service_config(self) -> ServiceConfig: + def service_config(self) -> ContainerConfig: """Get service config from parent container definition.""" if self.parent_container_definition is None: raise ValueError("Parent container definition is not set") return self.parent_container_definition.service_config - uuid: str # Format: {container_name == service_name}-{container_idx} - name: str # Name of the service - service_config: ServiceConfig + uuid: str # Format: {name}-{container_idx} + name: str + service_config: ContainerConfig service_instances: list[ServiceInstance] gpu: int | None context: WizardContext workdir: str | None environments: list[str] volumes: list[VolumeMount] + published_ports: dict[str, int] = field(default_factory=dict) @property def command(self) -> str: @@ -112,21 +131,21 @@ def command(self) -> str: pid_var = f"PID{i}" pid_vars.append(pid_var) script_lines.append(f"{cmd} &") - script_lines.append(f"{pid_var}=\\$!") + script_lines.append(f"{pid_var}=$!") script_lines.append("") # Add trap to kill all processes - pid_list = " ".join(f'"\\${pid}"' for pid in pid_vars) + pid_list = " ".join(f'"${pid}"' for pid in pid_vars) script_lines.append(f"trap 'kill {pid_list} 2>/dev/null' TERM INT") script_lines.append("") # Add wait loop script_lines.append("EXIT_CODE=0") - pid_list_wait = " ".join(f'"\\${pid}"' for pid in pid_vars) + pid_list_wait = " ".join(f'"${pid}"' for pid in pid_vars) script_lines.append(f"for pid in {pid_list_wait}; do") - script_lines.append(' wait "\\$pid" || EXIT_CODE=\\$?') + script_lines.append(' wait "$pid" || EXIT_CODE=$?') script_lines.append("done") - script_lines.append('exit "\\$EXIT_CODE"') + script_lines.append('exit "$EXIT_CODE"') return "\n".join(script_lines) @@ -162,13 +181,13 @@ def create( # Note: all service instances share the same ServiceConfig, volumes and environments first_instance = service_instances[0] - workdir = getattr(service_config, "workdir", None) + workdir = service_config.workdir environments = list(service_config.environments) - volumes: list[VolumeMount] = [] - for volume_str in service_config.volumes: - volumes.append(VolumeMount.from_str(volume_str)) + volumes = [ + VolumeMount.from_str(volume_str) for volume_str in service_config.volumes + ] - if getattr(context.cfg.wizard, "validate_mount_points", False): + if context.cfg.wizard.validate_mount_points: for volume in volumes: if not volume.host_exists(): raise FileNotFoundError( @@ -203,21 +222,15 @@ def _build_command( context: WizardContext, service_name: str, ) -> str: - # Build command with all replacements command = " ".join(service_config.command) - - assert ( - "{port}" not in command or port is not None - ), f"Port is required for {service_name}" - # Apply all variable replacements - command = command.replace("{port}", str(port)) - sceneset_path = getattr(context.cfg.scenes, "sceneset_path", None) + if "{port}" in command: + if port is None: + raise ValueError(f"Port is required for {service_name}") + command = command.replace("{port}", str(port)) + sceneset_path = context.cfg.scenes.sceneset_path command = command.replace("{sceneset}", sceneset_path or "None") - - # Runtime config name replacement runtime_config_name = f"generated-user-config-{int(os.environ.get('SLURM_ARRAY_TASK_ID', 0))}.yaml" command = command.replace("{runtime_config_name}", runtime_config_name) - return command @staticmethod @@ -252,8 +265,9 @@ def _build_address( class ContainerSet: """Container organization for deployment strategies.""" + prometheus: ContainerDefinition sim: list[ContainerDefinition] = field(default_factory=list) - runtime: list[ContainerDefinition] = field(default_factory=list) + runtime: ContainerDefinition | None = None def create_gpu_assigner(gpu_ids: List[int] | None) -> Iterator[int | None]: @@ -280,7 +294,7 @@ def build_container_set( ContainerSet populated with containers for all configured services """ cfg = context.cfg - num_gpus = context.get_num_gpus() + num_gpus = context.num_gpus # Overwrite from config use_address_string = ( @@ -301,8 +315,8 @@ def build_service_containers( return [] # Check if service should be skipped (skip: true in runtime config) - if runtime_cfg: - endpoints = getattr(runtime_cfg, "endpoints", {}) + if runtime_cfg is not None and "endpoints" in runtime_cfg: + endpoints = runtime_cfg.endpoints service_endpoint = endpoints.get(service_name, {}) if service_endpoint.get("skip", False): logger.debug(f"Skipping service {service_name} (marked as skip)") @@ -384,7 +398,7 @@ def build_service_containers( # Build containers for each service type sim_containers = [] - runtime_containers = [] + runtime_container = None # Simulation services for name in cfg.wizard.run_sim_services or []: @@ -414,24 +428,76 @@ def build_service_containers( command=command, address=runtime_address, ) - runtime_containers = [ - ContainerDefinition.create( - name="runtime", - service_instances=[runtime_instance], - service_config=cfg.services.runtime, - gpu=None, - context=context, - ) - ] + runtime_container = ContainerDefinition.create( + name="runtime", + service_instances=[runtime_instance], + service_config=cfg.services.runtime, + gpu=None, + context=context, + ) + runtime_container.published_ports = ( + context.telemetry_ports.runtime_worker_ports() + ) else: - if config := getattr(cfg.services, name, None): + config = getattr(cfg.services, name) + if config is not None: sim_containers.extend( build_service_containers(name, config, cfg.runtime) ) + prometheus_container = _build_prometheus_container( + cfg, + context, + use_address_string, + ) + logger.info("Built %d simulation containers", len(sim_containers)) + logger.info("Built Prometheus container %s", prometheus_container.uuid) return ContainerSet( sim=sim_containers, - runtime=runtime_containers, + prometheus=prometheus_container, + runtime=runtime_container, + ) + + +def _build_prometheus_container( + cfg: Any, + context: WizardContext, + use_address_string: Literal["localhost", "0.0.0.0", "uuid"], +) -> ContainerDefinition: + name = "prometheus" + config = cfg.services.prometheus + prometheus_ports = context.telemetry_ports.prometheus_service_ports() + readiness_port = prometheus_ports["prometheus"] + uuid = f"{name}-0" + address = ContainerDefinition._build_address( + readiness_port, + uuid, + use_address_string, + ) + command = resolve_prometheus_command(context) + instance = ContainerDefinition.ServiceInstance( + replica_idx=0, + command=command, + address=address, + ) + volumes = [VolumeMount.from_str(volume_str) for volume_str in config.volumes] + if context.cfg.wizard.validate_mount_points: + for volume in volumes: + if not volume.host_exists(): + raise FileNotFoundError(f"Mount point does not exist: {volume.host}") + container = ContainerDefinition( + uuid=uuid, + name=name, + service_instances=[instance], + gpu=None, + service_config=config, + context=context, + workdir=config.workdir, + environments=list(config.environments), + volumes=volumes, + published_ports=prometheus_ports, ) + instance.parent_container_definition = container + return container diff --git a/src/wizard/alpasim_wizard/telemetry/__init__.py b/src/wizard/alpasim_wizard/telemetry/__init__.py new file mode 100644 index 00000000..a6209547 --- /dev/null +++ b/src/wizard/alpasim_wizard/telemetry/__init__.py @@ -0,0 +1,4 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation + +"""Telemetry config generation helpers.""" diff --git a/src/wizard/alpasim_wizard/telemetry/prometheus.py b/src/wizard/alpasim_wizard/telemetry/prometheus.py new file mode 100644 index 00000000..7c06dea0 --- /dev/null +++ b/src/wizard/alpasim_wizard/telemetry/prometheus.py @@ -0,0 +1,254 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation + +"""Prometheus and file-SD config generation.""" + +from __future__ import annotations + +import json +import logging +import os +import socket +import time +from concurrent.futures import ThreadPoolExecutor +from contextlib import suppress +from importlib.resources import files as resource_files +from pathlib import Path +from typing import Any + +from alpasim_wizard.context import WizardContext +from alpasim_wizard.utils import write_json, write_yaml + +logger = logging.getLogger(__name__) + +TELEMETRY_LOG_DIR = "/mnt/log_dir" +PROMETHEUS_CONFIG = f"{TELEMETRY_LOG_DIR}/prometheus/prometheus.yml" +PROMETHEUS_TARGETS = f"{TELEMETRY_LOG_DIR}/prometheus/targets" +PROMETHEUS_RULES = f"{TELEMETRY_LOG_DIR}/prometheus/rules" +PROCESS_EXPORTER_CONFIG = f"{TELEMETRY_LOG_DIR}/prometheus/process-exporter.yml" +PROMETHEUS_DATA = f"{TELEMETRY_LOG_DIR}/prometheus/data" + +FILE_SD_CLEANUP_MIN_AGE_S = 5 * 60 * 60 +FILE_SD_CLEANUP_TIMEOUT_S = 1.0 +FILE_SD_CLEANUP_MAX_WORKERS = 32 + + +def _base_file_sd_labels(run_metadata: dict[str, Any], cfg: Any) -> dict[str, str]: + """Build labels shared by every file-SD target group for a run.""" + return { + "run_uuid": str(run_metadata["run_uuid"]), + "run_name": str(run_metadata["run_name"]), + "user": str(os.environ.get("USER", "unknownUser")), + "node": socket.gethostname(), + "slurm_job_id": str(cfg.wizard.slurm_job_id or ""), + } + + +def _host_log_path(log_dir: Path, container_path: str) -> Path: + """Map a telemetry container path under /mnt/log_dir to its host log path.""" + return log_dir / Path(container_path).relative_to(TELEMETRY_LOG_DIR) + + +def generate_prometheus_configs( + log_dir: Path, + run_metadata: dict[str, Any], + context: WizardContext, +) -> Path | None: + """Write Prometheus config, rules, and file-SD targets for this run. + + The generated files live under the run log directory, which is mounted into + the telemetry sidecar at the container paths defined in this module. The + optional central file-SD publication lets an external Prometheus discover + this run while it is active. + + Args: + log_dir: Host-side run log directory. + run_metadata: Stable run identity labels written into scrape targets. + context: Wizard context containing resolved config and telemetry ports. + + Returns: + The central file-SD path to remove during cleanup, or None when central + file-SD publication is disabled. + """ + cfg = context.cfg + prometheus_config_path = _host_log_path(log_dir, PROMETHEUS_CONFIG) + targets_dir = _host_log_path(log_dir, PROMETHEUS_TARGETS) + data_dir = _host_log_path(log_dir, PROMETHEUS_DATA) + rules_dir = _host_log_path(log_dir, PROMETHEUS_RULES) + targets_dir.mkdir(parents=True, exist_ok=True) + data_dir.mkdir(parents=True, exist_ok=True) + rules_dir.mkdir(parents=True, exist_ok=True) + + write_json( + { + "process_names": [ + {"name": "runtime", "cmdline": ["alpasim_runtime.simulate"]}, + {"name": "driver", "cmdline": ["alpasim_driver"]}, + {"name": "renderer", "cmdline": ["pycena|nre|sensorsim"]}, + {"name": "physics", "cmdline": ["physics_server"]}, + {"name": "trafficsim", "cmdline": ["trafficsim"]}, + {"name": "controller", "cmdline": ["alpasim_controller.server"]}, + ] + }, + _host_log_path(log_dir, PROCESS_EXPORTER_CONFIG), + ) + + local_targets = _build_file_sd_targets(run_metadata, cfg, context, local=True) + write_json(local_targets, targets_dir / "alpasim.json") + + central_file_sd_path = None + file_sd_root = ( + Path(cfg.wizard.prometheus.file_sd_dir) + if cfg.wizard.prometheus.file_sd_dir + else None + ) + if file_sd_root: + _cleanup_stale_file_sd(file_sd_root) + central_path = file_sd_root / f"{run_metadata['run_uuid']}.json" + file_sd_root.mkdir(parents=True, exist_ok=True) + write_json( + _build_file_sd_targets(run_metadata, cfg, context, local=False), + central_path, + ) + central_file_sd_path = central_path + + prometheus_config = { + "global": { + "scrape_interval": str(cfg.wizard.prometheus.scrape_interval), + "evaluation_interval": str(cfg.wizard.prometheus.scrape_interval), + }, + "rule_files": [f"{PROMETHEUS_RULES}/*.yml"], + "scrape_configs": [ + { + "job_name": "alpasim", + "file_sd_configs": [ + { + "files": [f"{PROMETHEUS_TARGETS}/*.json"], + "refresh_interval": "5s", + } + ], + } + ], + } + recording_rules = resource_files("alpasim_utils.telemetry").joinpath( + "metrics_plot_recording_rules.yml" + ) + (rules_dir / "alpasim-recording-rules.yml").write_text( + recording_rules.read_text(encoding="utf-8"), + encoding="utf-8", + ) + write_yaml(prometheus_config, str(prometheus_config_path)) + return central_file_sd_path + + +def _build_file_sd_targets( + run_metadata: dict[str, Any], + cfg: Any, + context: WizardContext, + *, + local: bool, +) -> list[dict[str, Any]]: + """Build Prometheus file-SD target groups for local or central scraping.""" + labels = _base_file_sd_labels(run_metadata, cfg) + if local: + runtime_host = ( + "localhost" + if cfg.wizard.run_method.name == "SLURM" + or cfg.wizard.debug_flags.use_localhost + else "runtime-0" + ) + exporter_host = "localhost" + else: + runtime_host = _central_scrape_host() + exporter_host = runtime_host + + telemetry_ports = context.telemetry_ports + prometheus_ports = telemetry_ports.prometheus_service_ports() + return [ + { + "targets": [f"{runtime_host}:{port}" for port in telemetry_ports.workers], + "labels": {**labels, "job": "alpasim-runtime-worker"}, + }, + { + "targets": [f"{exporter_host}:{prometheus_ports['node_exporter']}"], + "labels": {**labels, "job": "alpasim-node"}, + }, + { + "targets": [f"{exporter_host}:{prometheus_ports['process_exporter']}"], + "labels": {**labels, "job": "alpasim-process"}, + }, + { + "targets": [f"{exporter_host}:{prometheus_ports['dcgm_exporter']}"], + "labels": {**labels, "job": "alpasim-dcgm"}, + }, + ] + + +def _central_scrape_host() -> str: + """Return a scrape address reachable by external Prometheus servers.""" + hostname = socket.gethostname() + try: + return socket.gethostbyname(hostname) + except socket.gaierror: + return hostname + + +def _cleanup_stale_file_sd(file_sd_dir: Path) -> None: + """Delete stale central file-SD files whose targets are unreachable. + + Central file-SD entries are meant to exist only while a run is active. This + cleanup prevents old runs from staying discoverable if the wizard process + exited before `cleanup_central_file_sd` could remove its own entry. + + Args: + file_sd_dir: Root directory containing Prometheus file-SD JSON files. + """ + now = time.time() + for path in file_sd_dir.glob("*.json"): + try: + if now - path.stat().st_mtime < FILE_SD_CLEANUP_MIN_AGE_S: + continue + + with open(path, encoding="utf-8") as f: + groups = json.load(f) + if not isinstance(groups, list): + raise TypeError("expected a list of file-SD target groups") + targets: list[str] = [] + for group in groups: + if not isinstance(group, dict): + raise TypeError("expected each file-SD target group to be a dict") + group_targets = group.get("targets") + if not isinstance(group_targets, list): + raise TypeError( + "expected file-SD target group targets to be a list" + ) + if not all(isinstance(target, str) for target in group_targets): + raise TypeError("expected all file-SD targets to be strings") + targets.extend(group_targets) + + with ThreadPoolExecutor( + max_workers=FILE_SD_CLEANUP_MAX_WORKERS + ) as executor: + if any(executor.map(_target_reachable, targets)): + continue + except (OSError, json.JSONDecodeError, TypeError, ValueError) as exc: + logger.warning("Skipping invalid file-SD file %s: %s", path, exc) + continue + + path.unlink() + with suppress(OSError): + path.parent.rmdir() + + +def _target_reachable(target: str) -> bool: + """Return whether a host:port target accepts a TCP connection.""" + host, port_str = target.rsplit(":", 1) + port = int(port_str) + try: + with socket.create_connection( + (host, port), + timeout=FILE_SD_CLEANUP_TIMEOUT_S, + ): + return True + except OSError: + return False diff --git a/src/wizard/alpasim_wizard/telemetry/resources/prometheus_sidecar.sh b/src/wizard/alpasim_wizard/telemetry/resources/prometheus_sidecar.sh new file mode 100644 index 00000000..06d101e6 --- /dev/null +++ b/src/wizard/alpasim_wizard/telemetry/resources/prometheus_sidecar.sh @@ -0,0 +1,72 @@ +set -euo pipefail + +if command -v prometheus >/dev/null 2>&1; then + PROMETHEUS_BIN=prometheus +else + echo "prometheus binary not found" >&2 + exit 1 +fi + +if command -v node_exporter >/dev/null 2>&1; then + NODE_EXPORTER_BIN=node_exporter +elif command -v prometheus-node-exporter >/dev/null 2>&1; then + NODE_EXPORTER_BIN=prometheus-node-exporter +else + echo "node_exporter binary not found" >&2 + exit 1 +fi + +if command -v process-exporter >/dev/null 2>&1; then + PROCESS_EXPORTER_BIN=process-exporter +elif command -v prometheus-process-exporter >/dev/null 2>&1; then + PROCESS_EXPORTER_BIN=prometheus-process-exporter +else + PROCESS_EXPORTER_BIN= +fi + +start_slurm_process_exporter() { + uv run --no-sync --project /repo/src/wizard \ + python -m alpasim_wizard.telemetry.slurm_process_exporter \ + --port="{prometheus_ports.process_exporter}" \ + --procfs=/host/proc \ + --cgroupfs=/host/sys/fs/cgroup & + PROCESS_PID=$! +} + +$NODE_EXPORTER_BIN \ + --web.listen-address=0.0.0.0:{prometheus_ports.node_exporter} \ + --path.procfs=/host/proc \ + --path.sysfs=/host/sys \ + --path.rootfs=/rootfs \ + --no-collector.systemd & +NODE_PID=$! + +if [[ -n "${SLURM_JOB_ID:-}" ]]; then + start_slurm_process_exporter +else + if [[ -z "${PROCESS_EXPORTER_BIN}" ]]; then + echo "process-exporter binary not found" >&2 + exit 1 + fi + $PROCESS_EXPORTER_BIN \ + --web.listen-address=0.0.0.0:{prometheus_ports.process_exporter} \ + --procfs=/host/proc \ + --config.path=/mnt/log_dir/prometheus/process-exporter.yml & + PROCESS_PID=$! +fi + +if command -v dcgm-exporter >/dev/null 2>&1; then + dcgm-exporter -a :{prometheus_ports.dcgm_exporter} & + DCGM_PID=$! +else + echo "dcgm-exporter binary not found; GPU exporter disabled" >&2 + DCGM_PID= +fi + +trap 'kill "$NODE_PID" "$PROCESS_PID" "$DCGM_PID" 2>/dev/null || true' TERM INT + +exec $PROMETHEUS_BIN \ + --config.file=/mnt/log_dir/prometheus/prometheus.yml \ + --storage.tsdb.path=/mnt/log_dir/prometheus/data \ + --enable-feature=promql-at-modifier \ + --web.listen-address=0.0.0.0:{prometheus_ports.prometheus} diff --git a/src/wizard/alpasim_wizard/telemetry/slurm_process_exporter.py b/src/wizard/alpasim_wizard/telemetry/slurm_process_exporter.py new file mode 100644 index 00000000..bd27de98 --- /dev/null +++ b/src/wizard/alpasim_wizard/telemetry/slurm_process_exporter.py @@ -0,0 +1,345 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation + +"""Lightweight Slurm-scoped process metrics exporter.""" + +from __future__ import annotations + +import argparse +import http.server +import logging +import os +import re +import subprocess +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Iterable, Sequence, cast + +GROUP_PATTERNS = ( + ("runtime", re.compile(r"alpasim_runtime\.simulate")), + ("driver", re.compile(r"alpasim_driver")), + ("physics", re.compile(r"physics_server")), + ("renderer", re.compile(r"pycena|sensorsim|(?:^|\W)nre(?:\W|$)")), + ("trafficsim", re.compile(r"trafficsim")), + ("controller", re.compile(r"alpasim_controller\.server")), +) + +CLOCK_TICKS = os.sysconf(os.sysconf_names["SC_CLK_TCK"]) +PAGE_SIZE = os.sysconf("SC_PAGE_SIZE") +logger = logging.getLogger(__name__) + +CommandRunner = Callable[[Sequence[str]], subprocess.CompletedProcess[str]] + + +@dataclass +class ProcessSample: + cpu_seconds: float = 0.0 + resident_bytes: int = 0 + + +@dataclass(frozen=True) +class ProcessMetric: + pid: str + group: str + port: str + cpu_seconds: float + resident_bytes: int + + +def _read_text(path: Path) -> str: + return path.read_text(encoding="utf-8", errors="replace") + + +def _read_cmdline(pid_dir: Path) -> str: + raw = pid_dir.joinpath("cmdline").read_bytes() + return raw.replace(b"\0", b" ").decode("utf-8", errors="replace").strip() + + +def _read_environ(pid_dir: Path) -> list[str]: + raw = pid_dir.joinpath("environ").read_bytes() + return [ + value.decode("utf-8", errors="replace") for value in raw.split(b"\0") if value + ] + + +def _read_cgroup(pid_dir: Path) -> str: + return _read_text(pid_dir / "cgroup") + + +def _read_cpu_seconds(pid_dir: Path) -> float: + stat = _read_text(pid_dir / "stat") + fields = stat[stat.rfind(")") + 2 :].split() + utime = int(fields[11]) + stime = int(fields[12]) + return (utime + stime) / CLOCK_TICKS + + +def _read_resident_bytes(pid_dir: Path) -> int: + statm_fields = _read_text(pid_dir / "statm").split() + return int(statm_fields[1]) * PAGE_SIZE + + +def _group_for_cmdline(cmdline: str) -> str | None: + for name, pattern in GROUP_PATTERNS: + if pattern.search(cmdline): + return name + return None + + +def _port_for_cmdline(cmdline: str) -> str: + match = re.search(r"(?:^|\s)(?:--?)?port(?:=|\s+)(\d+)(?:\s|$)", cmdline) + return match.group(1) if match else "" + + +def _run_command(command: Sequence[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run(command, check=True, capture_output=True, text=True) + + +def parse_slurm_pids(output: str) -> set[str]: + pids = set() + for line in output.splitlines(): + fields = line.split() + if not fields: + continue + pid = fields[0] + if pid == "-1" or not pid.isdigit(): + continue + pids.add(pid) + return pids + + +def discover_slurm_pids( + job_id: str, + *, + run_command: CommandRunner = _run_command, +) -> set[str]: + try: + result = run_command(["scontrol", "listpids", job_id]) + except FileNotFoundError as exc: + raise RuntimeError(str(exc)) from exc + except subprocess.CalledProcessError as exc: + detail = exc.stderr or exc.stdout or str(exc) + raise RuntimeError(detail) from exc + return parse_slurm_pids(result.stdout) + + +def discover_cgroup_pids(cgroupfs: Path, job_id: str) -> set[str]: + """Read process IDs from the host Slurm job cgroup.""" + job_dirs = { + *cgroupfs.glob(f"*/slurm/uid_*/job_{job_id}"), + *cgroupfs.glob(f"slurm/uid_*/job_{job_id}"), + } + pids: set[str] = set() + for job_dir in job_dirs: + for path in job_dir.rglob("cgroup.procs"): + try: + pids.update( + line for line in _read_text(path).splitlines() if line.isdigit() + ) + except (FileNotFoundError, PermissionError): + continue + return pids + + +def discover_procfs_pids(procfs: Path, job_id: str) -> set[str]: + pids = set() + job_env_names = ("SLURM_JOB_ID", "SLURM_JOBID") + job_cgroup = f"/job_{job_id}/" + for pid_dir in procfs.iterdir(): + if not pid_dir.name.isdigit(): + continue + try: + environ = _read_environ(pid_dir) + cgroup = _read_cgroup(pid_dir) + if not any(f"{name}={job_id}" in environ for name in job_env_names) and ( + job_cgroup not in cgroup + ): + continue + if _group_for_cmdline(_read_cmdline(pid_dir)) is None: + continue + except (FileNotFoundError, ProcessLookupError, PermissionError): + continue + pids.add(pid_dir.name) + return pids + + +def discover_pids( + job_id: str, + procfs: Path, + cgroupfs: Path = Path("/host/sys/fs/cgroup"), +) -> set[str]: + pids = discover_cgroup_pids(cgroupfs, job_id) + if pids: + return pids + try: + pids = discover_slurm_pids(job_id) + except RuntimeError as exc: + logger.warning( + "Failed to discover Slurm PIDs for job %s: %s; falling back to %s", + job_id, + exc, + procfs, + ) + pids = set() + return pids | discover_procfs_pids(procfs, job_id) + + +def collect(procfs: Path, pids: Iterable[str]) -> dict[str, ProcessSample]: + samples: dict[str, ProcessSample] = {} + for process in collect_processes(procfs, pids): + sample = samples.setdefault(process.group, ProcessSample()) + sample.cpu_seconds += process.cpu_seconds + sample.resident_bytes += process.resident_bytes + return samples + + +def collect_processes(procfs: Path, pids: Iterable[str]) -> list[ProcessMetric]: + samples = [] + for pid in pids: + pid_dir = procfs / pid + try: + cmdline = _read_cmdline(pid_dir) + group = _group_for_cmdline(cmdline) + if group is None: + continue + samples.append( + ProcessMetric( + pid=pid, + group=group, + port=_port_for_cmdline(cmdline), + cpu_seconds=_read_cpu_seconds(pid_dir), + resident_bytes=_read_resident_bytes(pid_dir), + ) + ) + except (FileNotFoundError, ProcessLookupError, PermissionError, ValueError): + continue + return samples + + +def _label_value(value: str) -> str: + return value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") + + +def render_metrics( + samples: dict[str, ProcessSample], + duration: float, + process_samples: Sequence[ProcessMetric] = (), +) -> bytes: + lines = [ + "# HELP namedprocess_namegroup_cpu_seconds_total Cpu usage in seconds", + "# TYPE namedprocess_namegroup_cpu_seconds_total counter", + ] + for group, sample in samples.items(): + label = _label_value(group) + lines.append( + f'namedprocess_namegroup_cpu_seconds_total{{groupname="{label}"}} ' + f"{sample.cpu_seconds}" + ) + lines.extend( + [ + "# HELP namedprocess_namegroup_memory_bytes Memory usage in bytes", + "# TYPE namedprocess_namegroup_memory_bytes gauge", + ] + ) + for group, sample in samples.items(): + label = _label_value(group) + lines.append( + f'namedprocess_namegroup_memory_bytes{{groupname="{label}",' + f'memtype="resident"}} {sample.resident_bytes}' + ) + lines.extend( + [ + "# HELP alpasim_process_cpu_seconds_total " + "Cpu usage in seconds by process", + "# TYPE alpasim_process_cpu_seconds_total counter", + ] + ) + for process in process_samples: + group = _label_value(process.group) + pid = _label_value(process.pid) + port = _label_value(process.port) + lines.append( + f'alpasim_process_cpu_seconds_total{{groupname="{group}",' + f'pid="{pid}",port="{port}"}} {process.cpu_seconds}' + ) + lines.extend( + [ + "# HELP alpasim_slurm_process_exporter_scrape_duration_seconds " + "Time spent collecting Slurm process metrics", + "# TYPE alpasim_slurm_process_exporter_scrape_duration_seconds gauge", + f"alpasim_slurm_process_exporter_scrape_duration_seconds {duration}", + ] + ) + return ("\n".join(lines) + "\n").encode("utf-8") + + +class MetricsHandler(http.server.BaseHTTPRequestHandler): + cache_until = 0.0 + cache_body = b"" + + def do_GET(self) -> None: + if self.path not in ("/metrics", "/"): + self.send_error(404) + return + now = time.monotonic() + server = cast(MetricsServer, self.server) + if now >= MetricsHandler.cache_until: + started = time.monotonic() + pids = discover_pids(server.job_id, server.procfs, server.cgroupfs) + process_samples = collect_processes(server.procfs, pids) + samples: dict[str, ProcessSample] = {} + for process in process_samples: + sample = samples.setdefault(process.group, ProcessSample()) + sample.cpu_seconds += process.cpu_seconds + sample.resident_bytes += process.resident_bytes + duration = time.monotonic() - started + MetricsHandler.cache_body = render_metrics( + samples, + duration, + process_samples, + ) + MetricsHandler.cache_until = now + server.cache_seconds + self.send_response(200) + self.send_header("Content-Type", "text/plain; version=0.0.4") + self.send_header("Content-Length", str(len(MetricsHandler.cache_body))) + self.end_headers() + self.wfile.write(MetricsHandler.cache_body) + + def log_message(self, fmt: str, *args: object) -> None: + return + + +class MetricsServer(http.server.HTTPServer): + job_id: str + procfs: Path + cgroupfs: Path + cache_seconds: float + + +def _job_id_from_env() -> str: + job_id = os.environ.get("SLURM_JOB_ID") + if not job_id: + raise RuntimeError("SLURM_JOB_ID is required") + return job_id + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--job-id", default=None) + parser.add_argument("--port", required=True, type=int) + parser.add_argument("--procfs", default="/host/proc", type=Path) + parser.add_argument("--cgroupfs", default="/host/sys/fs/cgroup", type=Path) + parser.add_argument("--cache-seconds", default=5.0, type=float) + args = parser.parse_args() + + server = MetricsServer(("0.0.0.0", args.port), MetricsHandler) + server.job_id = args.job_id or _job_id_from_env() + server.procfs = args.procfs + server.cgroupfs = args.cgroupfs + server.cache_seconds = args.cache_seconds + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/src/wizard/alpasim_wizard/utils.py b/src/wizard/alpasim_wizard/utils.py index 75a96a08..57b35f73 100644 --- a/src/wizard/alpasim_wizard/utils.py +++ b/src/wizard/alpasim_wizard/utils.py @@ -1,6 +1,7 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright (c) 2025-2026 NVIDIA Corporation +import json import logging import os import re @@ -40,7 +41,15 @@ def represent_literal_str(dumper: yaml.Dumper, data: LiteralStr) -> yaml.ScalarN IndentedListDumper.add_representer(LiteralStr, represent_literal_str) with open(file_path, "w") as stream: - yaml.dump(data, stream, Dumper=IndentedListDumper) + yaml.dump(data, stream, Dumper=IndentedListDumper, sort_keys=False) + + +def write_json(data: Any, file_path: str | Path) -> None: + """Write indented JSON, creating the parent directory first.""" + path = Path(file_path) + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) def nre_image_to_nre_version(image: str) -> str: diff --git a/src/wizard/alpasim_wizard/wizard.py b/src/wizard/alpasim_wizard/wizard.py index 6f689bc6..4df12647 100644 --- a/src/wizard/alpasim_wizard/wizard.py +++ b/src/wizard/alpasim_wizard/wizard.py @@ -12,7 +12,6 @@ import git -# Import new refactored components from .configuration import ConfigurationManager from .context import WizardContext from .deployment import DockerComposeDeployment, SlurmDeployment @@ -41,7 +40,6 @@ def create(cfg: AlpasimConfig) -> AlpasimWizard: cfg.wizard.slurm_job_id = int(os.environ.get("SLURM_JOB_ID", "0")) context = WizardContext.create(cfg) - # Return wizard with context return AlpasimWizard( context=context, ) @@ -90,16 +88,19 @@ def cast(self) -> None: config_manager.generate_all(container_set, self.context) # Handle different run methods - if self.context.cfg.wizard.run_method == RunMethod.SLURM: - slurm_deployment.deploy_all_services() - elif self.context.cfg.wizard.run_method == RunMethod.DOCKER_COMPOSE: - docker_compose_deployment.deploy_all_services() - elif self.context.cfg.wizard.run_method == RunMethod.NONE: - logger.info( - "Config generated but not executed. " - "Run 'docker compose up --exit-code-from runtime-0' in %s " - "to start the simulation", - self.context.cfg.wizard.log_dir, - ) + try: + if self.context.cfg.wizard.run_method == RunMethod.SLURM: + slurm_deployment.deploy_all_services() + elif self.context.cfg.wizard.run_method == RunMethod.DOCKER_COMPOSE: + docker_compose_deployment.deploy_all_services() + elif self.context.cfg.wizard.run_method == RunMethod.NONE: + logger.info( + "Config generated but not executed. " + "Run 'docker compose up --exit-code-from runtime-0' in %s " + "to start the simulation", + self.context.cfg.wizard.log_dir, + ) + finally: + config_manager.cleanup_central_file_sd() logger.info("Alpasim finished") diff --git a/src/wizard/configs/base_config.yaml b/src/wizard/configs/base_config.yaml index cd68d88a..6c804f6c 100644 --- a/src/wizard/configs/base_config.yaml +++ b/src/wizard/configs/base_config.yaml @@ -29,7 +29,7 @@ defines: # defaults will work out of the box \/ drivers: "${defines.filesystem}/drivers" sensordata: "${defines.filesystem}/nre-artifacts" - trafficsim_map_cache: "${defines.filesystem}/trafficsim/unified_data_cache" + trafficsim_models: "${defines.filesystem}/trafficsim-models" renderer_entrypoint: "/app/internal/scripts/pycena/runtime/pycena_nrm_full" nre_max_workers: 4 @@ -95,6 +95,11 @@ wizard: # Global log level for all alpasim services (DEBUG, INFO, WARNING, ERROR) log_level: INFO + prometheus: + scrape_interval: 5s + # Central Prometheus file-SD publication directory. Deploy configs resolve + # `defines.filesystem`; override this to publish elsewhere. + file_sd_dir: "${defines.filesystem}/prometheus/file-sd" scenes: # Selection method (set one of these two) @@ -126,6 +131,11 @@ services: - "${defines.sensordata}/ego-hoods:/mnt/ego-hoods" # \/ environments lets you set environment variables inside the container environments: + # Pyxis/enroot sets HOME to /home/$USER on Slurm. In the NRE image, + # /home/$USER/.cache points at an unmounted Lustre path, so tinycudann + # cache creation fails unless HOME resolves to a writable container path. + - HOME=/tmp + - XDG_CACHE_HOME=/tmp/.cache # this may not be necessary but at least on COLOSSUS by default pytorch had really stupid # configuration running to excessively parallelizing everything - OMP_NUM_THREADS=1 @@ -180,7 +190,7 @@ services: trafficsim: image: ${defines.base_image} command: - - "echo 'not yet included'" + - "echo 'no traffic simulation configured'" controller: image: ${defines.base_image} @@ -223,6 +233,16 @@ services: - "--eval-config=/mnt/log_dir/eval-config.yaml" replicas_per_container: 1 # should only be one runtime orchestrator + prometheus: + image: ${services.runtime.image} + volumes: + - "${wizard.log_dir}:/mnt/log_dir" + - "${repo-relative:'src'}:/repo/src" + - "${repo-relative:'plugins'}:/repo/plugins" + - "/proc:/host/proc:ro" + - "/sys:/host/sys:ro" + - "/:/rootfs:ro" + runtime: # nr_workers and endpoints.*.n_concurrent_rollouts are set by topology configs. @@ -239,19 +259,19 @@ runtime: artifact_cache_size: 10 trajdata: - cache_location: "trajdata_cache" # Eventually "${defines.trajdata_cache_mount}", requires additional mount - desired_dt: 0.1 # 10 Hz sampling rate for trajectories - load_vector_map: true # Request vector map support from trajdata - rebuild_cache: false # Set to true to force rebuild cache - rebuild_maps: false # Set to true to force rebuild maps - num_workers: 4 # Parallel workers for cache creation + cache_location: "trajdata_cache" # Eventually "${defines.trajdata_cache_mount}", requires additional mount + desired_dt: 0.1 # 10 Hz sampling rate for trajectories + load_vector_map: true # Request vector map support from trajdata + rebuild_cache: false # Set to true to force rebuild cache + rebuild_maps: false # Set to true to force rebuild maps + num_workers: 4 # Parallel workers for cache creation dataset: name: null data_dir: null extra_params: - config_dir: null # Set to enable YAML batch preprocessing mode - num_timesteps_before: 30 # Timesteps before central token - num_timesteps_after: 80 # Timesteps after central token + config_dir: null # Set to enable YAML batch preprocessing mode + num_timesteps_before: 30 # Timesteps before central token + num_timesteps_after: 80 # Timesteps after central token # Enable cubic spline smoothing for trajectories smooth_trajectories: true @@ -278,7 +298,7 @@ runtime: send_recording_ground_truth: false # Disable sending ground truth data to the driver # Which sensorsim bundled-render RPC to use (NONE = one render_rgb per camera). # Options: NONE | BATCH_RENDER_RGB (NRE) | RENDER_AGGREGATED (ODL). - render_bundling: NONE + render_bundling: BATCH_RENDER_RGB control_timestep_us: 100_000 pose_reporting_interval_us: 0 # 0 = controller reports only the final pose @@ -396,4 +416,6 @@ eval: - DRIVER_RESPONSES - ROUTE - AGENTS + - AGENT_IDS + - TRAFFIC_PREDICTIONS # driver config is loaded from the driver group (e.g., driver=vavam) diff --git a/src/wizard/configs/deploy/local_arm.yaml b/src/wizard/configs/deploy/local_arm.yaml index 52609be8..097d0ec9 100644 --- a/src/wizard/configs/deploy/local_arm.yaml +++ b/src/wizard/configs/deploy/local_arm.yaml @@ -22,6 +22,8 @@ services: image: nvcr.io/nvidia/nre/nre-ga:26.02 external_image: true environments: + - HOME=/tmp + - XDG_CACHE_HOME=/tmp/.cache - OMP_NUM_THREADS=1 # Blackwell GPUs (compute 10.3) not recognized by PyTorch in NRE image. # Force 9.0+PTX for forward compatibility so slangtorch JIT doesn't crash. diff --git a/src/wizard/configs/topology/2gpu.yaml b/src/wizard/configs/topology/2gpu.yaml index 83cf4833..cbd6f462 100644 --- a/src/wizard/configs/topology/2gpu.yaml +++ b/src/wizard/configs/topology/2gpu.yaml @@ -6,8 +6,6 @@ defines: services: renderer: - environments: - - OMP_NUM_THREADS=1 replicas_per_container: 1 gpus: [1] diff --git a/src/wizard/configs/topology/8gpu_64rollouts.yaml b/src/wizard/configs/topology/8gpu_64rollouts.yaml index c578ebe5..891253b2 100644 --- a/src/wizard/configs/topology/8gpu_64rollouts.yaml +++ b/src/wizard/configs/topology/8gpu_64rollouts.yaml @@ -10,9 +10,6 @@ eval: services: renderer: - environments: - - OMP_NUM_THREADS=1 - - PYTORCH_CUDA_ALLOC_CONF=garbage_collection_threshold:0.7 replicas_per_container: 1 gpus: [0, 1, 2, 3] diff --git a/src/wizard/configs/topology/8gpu_no_replicas.yaml b/src/wizard/configs/topology/8gpu_no_replicas.yaml index be3794f9..3094b1b7 100644 --- a/src/wizard/configs/topology/8gpu_no_replicas.yaml +++ b/src/wizard/configs/topology/8gpu_no_replicas.yaml @@ -9,9 +9,6 @@ defines: services: renderer: - environments: - - OMP_NUM_THREADS=1 - gpus: [3, 4, 5, 6, 7] replicas_per_container: 1 diff --git a/src/wizard/configs/trafficsim/catk.yaml b/src/wizard/configs/trafficsim/catk.yaml new file mode 100644 index 00000000..64c0d6d9 --- /dev/null +++ b/src/wizard/configs/trafficsim/catk.yaml @@ -0,0 +1,56 @@ +# @package _global_ +# CATK-based trafficsim service (integrated into the base image). +# Usage: trafficsim=catk +# +# Runs the CATK traffic prediction model from the alpasim base image. +# Requires model weights checked in via Git LFS under data/trafficsim-models. +# +# Static CATK service settings are written to trafficsim-config.yaml. Dynamic +# wizard values such as the allocated port and sceneset path remain CLI overrides. + +defaults: + - _self_ + +runtime: + endpoints: + trafficsim: + skip: false + +services: + trafficsim: + image: ${services.runtime.image} + volumes: + - "${scenes.scene_cache}:/mnt/nre-data" + - "${defines.trafficsim_models}:/mnt/trafficsim-models" + - "${wizard.log_dir}:/mnt/log_dir" + - "${repo-relative:'src'}:/repo/src" + command: + - "uv run catk_trafficsim_server" + - "--config-path=/mnt/log_dir" + - "--config-name=trafficsim-config.yaml" + - "server.port={port}" + - "catk.loader.usdz_folder=/mnt/nre-data/{sceneset}" + +trafficsim: + hydra: + job: + chdir: false + server: + host: 0.0.0.0 + port: 6200 + max_workers: 1 + log_file: /mnt/log_dir/txt-logs/trafficsim.log + catk: + device: cuda + filter_distance_th: 100.0 + predict_static: false + min_valid_history_steps: 5 + loader: + usdz_folder: /mnt/nre-data + time_step: 0.1 + num_history_steps: 16 + minimum_future_steps: 45 + model: + config_path: /mnt/trafficsim-models/catk_v120/config.yaml + ckpt_path: /mnt/trafficsim-models/catk_v120/latest.ckpt + token_pkl_dir: /mnt/trafficsim-models/tokens diff --git a/src/wizard/pyproject.toml b/src/wizard/pyproject.toml index 64ae7893..c577b9c7 100644 --- a/src/wizard/pyproject.toml +++ b/src/wizard/pyproject.toml @@ -42,6 +42,9 @@ asyncio_default_fixture_loop_scope = "function" # at startup, enabling automatic config search path registration from plugins. include = ["alpasim_wizard*", "hydra_plugins*"] +[tool.setuptools.package-data] +alpasim_wizard = ["telemetry/resources/*.sh"] + [project.scripts] alpasim_wizard = "alpasim_wizard.__main__:main" alpasim_check_config = "alpasim_wizard.check_config:main" diff --git a/src/wizard/tests/test_docker_compose_deployment.py b/src/wizard/tests/test_docker_compose_deployment.py index f26b9bd2..c5664ddc 100644 --- a/src/wizard/tests/test_docker_compose_deployment.py +++ b/src/wizard/tests/test_docker_compose_deployment.py @@ -8,8 +8,14 @@ from types import SimpleNamespace import pytest +import yaml from alpasim_wizard.deployment.docker_compose import DockerComposeDeployment from alpasim_wizard.schema import RunMode +from alpasim_wizard.services import Address, ContainerSet + + +def _local_addresses(*ports: int) -> list[Address]: + return [Address(host="127.0.0.1", port=port) for port in ports] def _deployment(tmp_path: Path, *, dry_run: bool) -> DockerComposeDeployment: @@ -22,13 +28,39 @@ def _deployment(tmp_path: Path, *, dry_run: bool) -> DockerComposeDeployment: debug_flags=SimpleNamespace(use_localhost=False), run_mode=RunMode.ONESHOT, ) - ) + ), + num_gpus=0, ) - deployment.container_set = SimpleNamespace(runtime=[object()]) + deployment.container_set = SimpleNamespace(runtime=object()) deployment.docker_compose_filepath = "docker-compose.yaml" return deployment +def _prometheus_container( + *, + name: str, + port_name: str, + port: int, + command: str = "echo ok", +) -> SimpleNamespace: + return SimpleNamespace( + uuid=f"{name}-0", + name=name, + service_config=SimpleNamespace( + image=f"{name}-image", + external_image=True, + pull_policy="missing", + ), + volumes=[], + command=command, + workdir=None, + environments=[], + gpu=None, + published_ports={port_name: port}, + get_all_addresses=lambda: _local_addresses(port), + ) + + def test_docker_compose_dry_run_does_not_execute( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -68,9 +100,59 @@ def test_docker_compose_service_uses_configured_pull_policy(tmp_path: Path) -> N workdir=None, environments=[], gpu=None, - get_all_addresses=lambda: [], + published_ports={}, + get_all_addresses=lambda: _local_addresses(7100), ) service = deployment._to_docker_compose_service(container) assert service["pull_policy"] == "never" + + +def test_docker_compose_adds_prometheus_before_runtime( + tmp_path: Path, +) -> None: + deployment = DockerComposeDeployment.__new__(DockerComposeDeployment) + deployment.context = SimpleNamespace( + cfg=SimpleNamespace( + wizard=SimpleNamespace( + log_dir=str(tmp_path), + debug_flags=SimpleNamespace(use_localhost=False), + run_mode=RunMode.ONESHOT, + ) + ), + num_gpus=0, + ) + runtime_container = SimpleNamespace( + uuid="runtime-0", + name="runtime", + service_config=SimpleNamespace( + image="runtime-image", + external_image=True, + pull_policy="missing", + ), + volumes=[], + command="uv run python -m alpasim_runtime.simulate", + workdir=None, + environments=[], + gpu=None, + published_ports={}, + get_all_addresses=lambda: _local_addresses(6200), + ) + prometheus = _prometheus_container( + name="prometheus", port_name="prometheus", port=6100 + ) + + deployment.generate_docker_compose_yaml( + ContainerSet(sim=[], prometheus=prometheus, runtime=runtime_container) + ) + + compose = yaml.safe_load((tmp_path / "docker-compose.yaml").read_text()) + services = compose["services"] + assert list(services) == ["prometheus-0", "runtime-0"] + assert services["runtime-0"].get("pid") is None + assert services["runtime-0"].get("deploy") is None + assert services["prometheus-0"].get("pid") is None + assert services["prometheus-0"].get("cap_add") is None + assert services["prometheus-0"].get("deploy") is None + assert services["prometheus-0"]["ports"] == ["6100:6100"] diff --git a/src/wizard/tests/test_runtime_server_mode.py b/src/wizard/tests/test_runtime_server_mode.py index df20d556..2741b400 100644 --- a/src/wizard/tests/test_runtime_server_mode.py +++ b/src/wizard/tests/test_runtime_server_mode.py @@ -3,6 +3,9 @@ from __future__ import annotations +import json +import os +import time from pathlib import Path from types import SimpleNamespace from typing import Iterator @@ -10,16 +13,19 @@ import pytest import yaml from alpasim_wizard.configuration import ConfigurationManager -from alpasim_wizard.context import WizardContext +from alpasim_wizard.context import TelemetryPorts, WizardContext from alpasim_wizard.deployment.docker_compose import DockerComposeDeployment from alpasim_wizard.schema import ( + ContainerConfig, DebugFlags, + RunMethod, RunMode, RuntimeServiceConfig, ServiceConfig, ) from alpasim_wizard.services import build_container_set from alpasim_wizard.setup_omegaconf import validate_config +from alpasim_wizard.telemetry import prometheus from omegaconf import OmegaConf @@ -57,7 +63,11 @@ def _runtime_service() -> RuntimeServiceConfig: ) -def _cfg(tmp_path: Path, *, run_sim_services: list[str] | None = None): +def _cfg( + tmp_path: Path, + *, + run_sim_services: list[str] | None = None, +): if run_sim_services is None: run_sim_services = [ "driver", @@ -71,14 +81,19 @@ def _cfg(tmp_path: Path, *, run_sim_services: list[str] | None = None): return SimpleNamespace( wizard=SimpleNamespace( run_mode=RunMode.SERVER, - run_method=SimpleNamespace(name="DOCKER_COMPOSE"), + run_method=RunMethod.DOCKER_COMPOSE, run_sim_services=run_sim_services, runtime_server_port=None, debug_flags=DebugFlags(use_localhost=False), validate_mount_points=False, log_dir=str(tmp_path), external_services=None, + prometheus=SimpleNamespace( + scrape_interval="5s", + file_sd_dir=None, + ), slurm_job_id=0, + run_name="test-run", submitter=None, description=None, ), @@ -94,9 +109,11 @@ def _cfg(tmp_path: Path, *, run_sim_services: list[str] | None = None): trafficsim=_service(["trafficsim", "--port={port}"]), controller=_service(["controller", "--port={port}"]), runtime=_runtime_service(), + prometheus=_prometheus_service(), ), runtime=OmegaConf.create( { + "nr_workers": 2, "endpoints": {"do_shutdown": True}, "simulation_config": {}, } @@ -104,10 +121,27 @@ def _cfg(tmp_path: Path, *, run_sim_services: list[str] | None = None): ) +def _prometheus_service() -> ContainerConfig: + return ContainerConfig( + volumes=[], + image="test-image", + ) + + def _context(cfg, *, baseport: int = 6100) -> WizardContext: + port_assigner = _port_assigner(baseport) + nr_workers = int(cfg.runtime.nr_workers) + telemetry_ports = TelemetryPorts( + workers=tuple(next(port_assigner) for _ in range(nr_workers)), + prometheus=next(port_assigner), + node_exporter=next(port_assigner), + process_exporter=next(port_assigner), + dcgm_exporter=next(port_assigner), + ) return WizardContext( cfg=cfg, - port_assigner=_port_assigner(baseport), + port_assigner=port_assigner, + telemetry_ports=telemetry_ports, artifact_list=[], num_gpus=0, ) @@ -120,10 +154,15 @@ def test_server_mode_generates_and_publishes_runtime_endpoint(tmp_path: Path) -> deployment = DockerComposeDeployment.__new__(DockerComposeDeployment) deployment.context = context - runtime = container_set.runtime[0] + runtime = container_set.runtime + assert runtime is not None assert "--serve" in runtime.command - assert "--listen-address=0.0.0.0:6105" in runtime.command - assert deployment._to_docker_compose_service(runtime)["ports"] == ["6105:6105"] + assert "--listen-address=0.0.0.0:6111" in runtime.command + assert deployment._to_docker_compose_service(runtime)["ports"] == [ + "6100:6100", + "6101:6101", + "6111:6111", + ] manager = ConfigurationManager(str(tmp_path)) manager._generate_runtime_server_config(container_set, cfg) @@ -131,7 +170,7 @@ def test_server_mode_generates_and_publishes_runtime_endpoint(tmp_path: Path) -> endpoint = yaml.safe_load((tmp_path / "generated-runtime-server.yaml").read_text()) assert endpoint == { "host": "localhost", - "port": 6105, + "port": 6111, } @@ -160,11 +199,136 @@ def test_managed_renderer_is_written_as_renderer_endpoint(tmp_path: Path) -> Non network = yaml.safe_load((tmp_path / "generated-network-config.yaml").read_text()) assert network["renderer"]["endpoints"] == [ - {"address": "renderer-0:6101", "managed": True} + {"address": "renderer-0:6107", "managed": True} ] assert "sensorsim" not in network +def test_prometheus_configs_publish_runtime_targets_and_recording_rules( + tmp_path: Path, +) -> None: + cfg = _cfg(tmp_path) + context = _context(cfg, baseport=6100) + manager = ConfigurationManager(str(tmp_path)) + + run_metadata = manager._load_or_create_run_metadata(cfg) + manager._generate_runtime_config(cfg, [], context) + prometheus.generate_prometheus_configs( + tmp_path, + run_metadata, + context, + ) + manager._write_config("run_metadata.yaml", run_metadata) + + runtime_config = yaml.safe_load( + (tmp_path / "generated-user-config-0.yaml").read_text() + ) + assert runtime_config["prometheus"]["worker_ports"] == [6100, 6101] + assert runtime_config["prometheus"]["url"] == "http://prometheus-0:6102" + assert "scrape_interval" not in runtime_config["prometheus"] + assert "file_sd_dir" not in runtime_config["prometheus"] + assert "grafana" not in runtime_config["prometheus"] + + run_metadata = yaml.safe_load((tmp_path / "run_metadata.yaml").read_text()) + + targets = json.loads((tmp_path / "prometheus/targets/alpasim.json").read_text()) + worker_target = targets[0] + assert worker_target["targets"] == ["runtime-0:6100", "runtime-0:6101"] + assert worker_target["labels"]["job"] == "alpasim-runtime-worker" + assert worker_target["labels"]["run_uuid"] == run_metadata["run_uuid"] + assert worker_target["labels"]["run_name"] == "test-run" + assert "worker_id" not in worker_target["labels"] + + prometheus_config = yaml.safe_load( + (tmp_path / "prometheus/prometheus.yml").read_text() + ) + assert prometheus_config["rule_files"] == ["/mnt/log_dir/prometheus/rules/*.yml"] + + rules = yaml.safe_load( + (tmp_path / "prometheus/rules/alpasim-recording-rules.yml").read_text() + ) + rule_names = {rule["record"] for rule in rules["groups"][0]["rules"]} + assert "alpasim:rpc_queue_depth_at_start_latest:max" in rule_names + assert "alpasim:rpc_queue_depth_at_start_latest:min" in rule_names + assert "alpasim:simulation_rollouts_completed:sum" in rule_names + assert "alpasim:process_cpu_utilization_percent:max_by_group:rate30s" in rule_names + assert "alpasim:gpu_memory_pressure_percent:avg" in rule_names + + +def test_run_metadata_is_loaded_for_resume(tmp_path: Path) -> None: + cfg = _cfg(tmp_path) + existing_metadata = { + "run_time": "2026-06-23 12:00:00", + "run_name": "original-run", + "run_uuid": "original-run-uuid", + "slurm_job_id": 123, + "run_user": "original-user", + "run_dir": "/original", + "run_args": "original-args", + "submitter": None, + "description": None, + "test_suite_id": None, + } + (tmp_path / "run_metadata.yaml").write_text(yaml.dump(existing_metadata)) + + manager = ConfigurationManager(str(tmp_path)) + run_metadata = manager._load_or_create_run_metadata(cfg) + + assert run_metadata == existing_metadata + + +def test_file_sd_cleanup_removes_old_unreachable_files( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + file_sd_dir = tmp_path / "file-sd" + file_sd_dir.mkdir(parents=True) + old_file = file_sd_dir / "old.json" + new_file = file_sd_dir / "new.json" + payload = [ + {"targets": ["host-a:6100"], "labels": {"job": "alpasim-runtime-worker"}} + ] + old_file.write_text(json.dumps(payload)) + new_file.write_text(json.dumps(payload)) + old_mtime = time.time() - 6 * 60 * 60 + os.utime(old_file, (old_mtime, old_mtime)) + + monkeypatch.setattr(prometheus, "_target_reachable", lambda target: False) + + prometheus._cleanup_stale_file_sd(file_sd_dir) + + assert not old_file.exists() + assert new_file.exists() + + +def test_central_file_sd_publication_is_removed_on_cleanup( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + cfg = _cfg(tmp_path) + file_sd_dir = tmp_path / "central-file-sd" + cfg.wizard.prometheus.file_sd_dir = str(file_sd_dir) + context = _context(cfg, baseport=6100) + manager = ConfigurationManager(str(tmp_path)) + monkeypatch.setattr(prometheus.socket, "gethostbyname", lambda host: "192.0.2.10") + + run_metadata = manager._load_or_create_run_metadata(cfg) + manager._central_file_sd_path = prometheus.generate_prometheus_configs( + tmp_path, + run_metadata, + context, + ) + central_path = file_sd_dir / f"{run_metadata['run_uuid']}.json" + assert central_path.exists() + central_targets = json.loads(central_path.read_text()) + assert central_targets[0]["targets"] == ["192.0.2.10:6100", "192.0.2.10:6101"] + assert central_targets[0]["labels"]["node"] != "192.0.2.10" + + manager.cleanup_central_file_sd() + + assert not central_path.exists() + + def test_external_video_model_is_first_class_network_endpoint( tmp_path: Path, ) -> None: @@ -209,8 +373,8 @@ def test_combined_renderer_physics_maps_physics_to_renderer_container( network = yaml.safe_load((tmp_path / "generated-network-config.yaml").read_text()) assert network["renderer"]["endpoints"] == [ - {"address": "renderer-0:6100", "managed": True} + {"address": "renderer-0:6106", "managed": True} ] assert network["physics"]["endpoints"] == [ - {"address": "renderer-0:6100", "managed": True} + {"address": "renderer-0:6106", "managed": True} ] diff --git a/src/wizard/tests/test_slurm_deployment.py b/src/wizard/tests/test_slurm_deployment.py index 72d08857..7a5f8431 100644 --- a/src/wizard/tests/test_slurm_deployment.py +++ b/src/wizard/tests/test_slurm_deployment.py @@ -8,7 +8,7 @@ from types import SimpleNamespace import pytest -from alpasim_wizard.context import WizardContext +from alpasim_wizard.context import TelemetryPorts, WizardContext from alpasim_wizard.deployment.slurm import SlurmDeployment from alpasim_wizard.schema import DebugFlags, RunMode @@ -30,6 +30,13 @@ def _context(tmp_path: Path, *, dry_run: bool = False) -> WizardContext: return WizardContext( cfg=cfg, port_assigner=iter(()), + telemetry_ports=TelemetryPorts( + workers=(), + prometheus=6100, + node_exporter=6101, + process_exporter=6102, + dcgm_exporter=6103, + ), artifact_list=[], num_gpus=0, ) @@ -88,6 +95,29 @@ def test_slurm_run_exports_explicit_gpu_zero( assert expected in command +def test_slurm_run_isolates_submit_environment_except_job_id_unless_requested( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + deployment = _deployment(tmp_path) + monkeypatch.setattr( + "alpasim_wizard.deployment.slurm.ensure_sqsh_path", + lambda image, caches: f"{image}.sqsh", + ) + + default_command = deployment._to_slurm_run( + _slurm_container(deployment, None), RunMode.ONESHOT + ) + container = _slurm_container(deployment, None) + container.environments = ["HF_TOKEN", "HOME=/tmp", "XDG_CACHE_HOME=/tmp/.cache"] + explicit_command = deployment._to_slurm_run(container, RunMode.ONESHOT) + + assert "--export=SLURM_JOB_ID " in default_command + assert "--export=SLURM_JOB_ID,HF_TOKEN " in explicit_command + assert "export HOME=/tmp;" in explicit_command + assert "export XDG_CACHE_HOME=/tmp/.cache;" in explicit_command + + def test_slurm_cleanup_runs_after_blocking_runtime_srun( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, diff --git a/src/wizard/tests/test_slurm_process_exporter.py b/src/wizard/tests/test_slurm_process_exporter.py new file mode 100644 index 00000000..d574b434 --- /dev/null +++ b/src/wizard/tests/test_slurm_process_exporter.py @@ -0,0 +1,165 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 NVIDIA Corporation + +from __future__ import annotations + +from pathlib import Path + +import pytest +from alpasim_wizard.telemetry.slurm_process_exporter import ( + collect, + collect_processes, + discover_pids, + render_metrics, +) + + +def _write_proc( + procfs: Path, + pid: str, + *, + cmdline: str, + utime: int, + stime: int, + resident_pages: int, + environ: list[str] | None = None, + cgroup: str = "", +) -> None: + pid_dir = procfs / pid + pid_dir.mkdir() + (pid_dir / "cmdline").write_bytes(cmdline.encode("utf-8") + b"\0") + (pid_dir / "environ").write_bytes( + b"\0".join(value.encode("utf-8") for value in environ or []) + b"\0" + ) + fields_after_comm = ["S", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0"] + fields_after_comm.extend([str(utime), str(stime), "0", "0"]) + (pid_dir / "stat").write_text( + f"{pid} (python) {' '.join(fields_after_comm)}\n", + encoding="utf-8", + ) + (pid_dir / "statm").write_text( + f"0 {resident_pages} 0 0 0 0 0\n", + encoding="utf-8", + ) + (pid_dir / "cgroup").write_text(cgroup, encoding="utf-8") + + +def test_discover_pids_falls_back_to_job_scoped_procfs( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _write_proc( + tmp_path, + "101", + cmdline="uv run python -m alpasim_runtime.simulate", + utime=100, + stime=50, + resident_pages=10, + environ=["SLURM_JOBID=123"], + ) + _write_proc( + tmp_path, + "102", + cmdline="uv run python -m alpasim_driver.main", + utime=100, + stime=50, + resident_pages=10, + environ=["SLURM_JOB_ID=999"], + ) + _write_proc( + tmp_path, + "103", + cmdline="python unrelated.py", + utime=100, + stime=50, + resident_pages=10, + environ=["SLURM_JOB_ID=123"], + ) + _write_proc( + tmp_path, + "104", + cmdline="uv run python -m alpasim_controller.server --port=6132", + utime=100, + stime=50, + resident_pages=10, + cgroup="7:cpu,cpuacct:/slurm/uid_101499/job_123/step_1/task_0\n", + ) + + def raise_missing_scontrol(job_id: str) -> set[str]: + raise RuntimeError("[Errno 2] No such file or directory: 'scontrol'") + + monkeypatch.setattr( + "alpasim_wizard.telemetry.slurm_process_exporter.discover_slurm_pids", + raise_missing_scontrol, + ) + + assert discover_pids("123", tmp_path) == {"101", "104"} + + +def test_discover_pids_prefers_host_cgroup_without_scontrol( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + procfs = tmp_path / "proc" + procfs.mkdir() + _write_proc( + procfs, + "101", + cmdline="uv run physics_server --port=6116", + utime=100, + stime=50, + resident_pages=10, + ) + cgroupfs = tmp_path / "cgroup" + job_dir = cgroupfs / "freezer/slurm/uid_101499/job_123/step_1" + job_dir.mkdir(parents=True) + (job_dir / "cgroup.procs").write_text("101\n", encoding="utf-8") + + def fail_scontrol(job_id: str) -> set[str]: + raise AssertionError("scontrol must not be needed when the job cgroup exists") + + monkeypatch.setattr( + "alpasim_wizard.telemetry.slurm_process_exporter.discover_slurm_pids", + fail_scontrol, + ) + + pids = discover_pids("123", procfs, cgroupfs) + + assert pids == {"101"} + + +def test_render_metrics_omits_unobserved_groups(tmp_path: Path) -> None: + _write_proc( + tmp_path, + "101", + cmdline=( + "uv run physics_server --port=6116 " + "--artifact-glob=/mnt/nre-data/**/*.usdz" + ), + utime=100, + stime=50, + resident_pages=10, + ) + + process_samples = collect_processes(tmp_path, {"101"}) + payload = render_metrics( + collect(tmp_path, {"101"}), + duration=0.125, + process_samples=process_samples, + ).decode("utf-8") + + assert 'namedprocess_namegroup_cpu_seconds_total{groupname="physics"}' in payload + assert ( + 'alpasim_process_cpu_seconds_total{groupname="physics",pid="101",port="6116"}' + in payload + ) + assert 'namedprocess_namegroup_cpu_seconds_total{groupname="driver"}' not in payload + assert ( + 'namedprocess_namegroup_memory_bytes{groupname="physics",memtype="resident"}' + in payload + ) + assert ( + 'namedprocess_namegroup_memory_bytes{groupname="driver",memtype="resident"}' + not in payload + ) + assert "alpasim_slurm_process_exporter_scrape_duration_seconds 0.125" in payload