1mod fsck;
17pub mod logstats;
18#[doc(hidden)]
19pub mod pgs;
20mod runs;
21
22#[cfg(feature = "self-describing-logs")]
23pub mod catalog;
24#[cfg(feature = "self-describing-logs")]
25mod catalog_cli;
26#[cfg(all(feature = "self-describing-logs", feature = "python"))]
27mod catalog_python;
28#[cfg(all(test, feature = "self-describing-logs"))]
29mod catalog_tests;
30#[cfg(feature = "self-describing-logs")]
31mod value_export;
32
33#[cfg(feature = "mcap")]
34pub mod mcap_export;
35
36#[cfg(feature = "mcap")]
37pub mod serde_to_jsonschema;
38
39use bincode::Decode;
40use bincode::config::standard;
41use bincode::decode_from_std_read;
42use bincode::error::DecodeError;
43use clap::{Parser, Subcommand, ValueEnum};
44use cu29::UnifiedLogType;
45use cu29::prelude::*;
46use cu29_intern_strs::read_interned_strings;
47use fsck::check;
48#[cfg(feature = "mcap")]
49use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
50use logstats::{compute_logstats, write_logstats};
51use serde::Serialize;
52use std::collections::BTreeMap;
53use std::fmt::Write as _;
54use std::fmt::{Display, Formatter};
55#[cfg(feature = "mcap")]
56use std::io::IsTerminal;
57use std::io::Read;
58use std::path::{Path, PathBuf};
59
60#[cfg(feature = "mcap")]
61pub use mcap_export::{McapExportStats, export_to_mcap, export_to_mcap_with_schemas, mcap_info};
62
63#[cfg(feature = "mcap")]
64#[allow(deprecated)]
65pub use cu29::prelude::PayloadSchemas;
66
67#[cfg(feature = "mcap")]
68pub use serde_to_jsonschema::trace_type_to_jsonschema;
69
70#[cfg(feature = "python")]
75pub use python::register_copperlist_python_type;
76
77#[cfg(feature = "python")]
84pub fn copperlist_iterator_unified_typed_py<P>(
85 unified_src_path: &str,
86 py: pyo3::Python<'_>,
87) -> pyo3::PyResult<pyo3::Py<pyo3::PyAny>>
88where
89 P: CopperListTuple + 'static,
90{
91 let _ = cu29::logcodec::seed_effective_config_from_log::<P>(Path::new(unified_src_path))
92 .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
93 register_copperlist_python_type::<P>()
94 .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
95 let iter = python::copperlist_iterator_unified(unified_src_path)?;
96 pyo3::Py::new(py, iter).map(|obj| obj.into())
97}
98
99#[cfg(feature = "python")]
104pub fn runtime_lifecycle_iterator_unified_py(
105 unified_src_path: &str,
106 py: pyo3::Python<'_>,
107) -> pyo3::PyResult<pyo3::Py<pyo3::PyAny>> {
108 let iter = python::runtime_lifecycle_iterator_unified(unified_src_path)?;
109 pyo3::Py::new(py, iter).map(|obj| obj.into())
110}
111#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
112pub enum ExportFormat {
113 Json,
114 Csv,
115 Jsonl,
116}
117
118impl Display for ExportFormat {
119 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
120 match self {
121 ExportFormat::Json => write!(f, "json"),
122 ExportFormat::Csv => write!(f, "csv"),
123 ExportFormat::Jsonl => write!(f, "jsonl"),
124 }
125 }
126}
127
128#[cfg(feature = "self-describing-logs")]
130#[derive(Clone, Copy, PartialEq, Eq, ValueEnum)]
131pub enum CopperListDecoder {
132 Typed,
133 Catalog,
134}
135
136#[cfg(feature = "self-describing-logs")]
138#[derive(Clone, Copy, PartialEq, Eq, ValueEnum)]
139pub enum CatalogFormat {
140 Human,
141 Ron,
142 Json,
143}
144
145#[derive(Parser)]
147#[command(author, version, about)]
148pub struct LogReaderCli {
149 pub unifiedlog_base: PathBuf,
152
153 #[arg(long, global = true)]
155 pub run: Option<usize>,
156
157 #[arg(long, global = true, value_enum, default_value_t = clap::ColorChoice::Auto)]
159 pub color: clap::ColorChoice,
160
161 #[command(subcommand)]
162 pub command: Command,
163}
164
165#[derive(Subcommand)]
166pub enum Command {
167 ListRuns,
169 #[cfg(feature = "self-describing-logs")]
171 Catalog {
172 #[arg(short, long, value_enum, default_value = "human")]
173 export_format: CatalogFormat,
174 },
175 ExtractTextLog { log_index: PathBuf },
177 ExtractCopperlists {
179 #[arg(short, long, default_value_t = ExportFormat::Json)]
180 export_format: ExportFormat,
181 #[cfg(feature = "self-describing-logs")]
183 #[arg(long, value_enum, default_value = "typed")]
184 decoder: CopperListDecoder,
185 },
186 Fsck {
188 #[arg(short, long, action = clap::ArgAction::Count)]
189 verbose: u8,
190 #[arg(long)]
192 dump_runtime_lifecycle: bool,
193 #[arg(long)]
195 deep: bool,
196 },
197 LogStats {
199 #[arg(short, long, default_value = "cu29_logstats.json")]
201 output: PathBuf,
202 #[arg(long)]
204 config: Option<PathBuf>,
205 #[arg(long)]
207 mission: Option<String>,
208 #[arg(long, value_delimiter = ',')]
210 features: Vec<String>,
211 },
212 OptimizeSchedule {
214 #[arg(long)]
216 contract: PathBuf,
217 #[arg(short, long, default_value = "schedule")]
219 output: PathBuf,
220 #[arg(long, default_value_t = 3)]
222 candidates: usize,
223 #[arg(long = "measure", value_name = "NAME=LOG_BASE")]
225 measurements: Vec<String>,
226 },
227 #[cfg(feature = "mcap")]
229 ExportMcap {
230 #[arg(short, long)]
232 output: PathBuf,
233 #[arg(long)]
235 progress: bool,
236 #[arg(long)]
238 quiet: bool,
239 },
240 #[cfg(feature = "mcap")]
242 McapInfo {
243 mcap_file: PathBuf,
245 #[arg(short, long)]
247 schemas: bool,
248 #[arg(short = 'n', long, default_value_t = 0)]
250 sample_messages: usize,
251 },
252}
253
254#[cfg(feature = "self-describing-logs")]
256#[doc(hidden)]
257pub fn run_catalog_cli() -> CuResult<()> {
258 catalog_cli::run_cli()
259}
260
261fn print_runs(runs: &[runs::RecordedRun]) {
262 for run in runs {
263 println!(
264 "Run {}: mission={} app={} instance_id={} started={} shutdown={}",
265 run.index,
266 run.missions.join(","),
267 run.stack
268 .as_ref()
269 .map_or("unknown", |stack| stack.app_name.as_str()),
270 run.stack.as_ref().map_or_else(
271 || "unknown".to_string(),
272 |stack| stack.instance_id.to_string()
273 ),
274 run.started_at
275 .map_or_else(|| "unknown".to_string(), |time| time.to_string()),
276 run.shutdown_completed,
277 );
278 }
279}
280
281fn write_json_pretty<T: Serialize + ?Sized>(value: &T) -> CuResult<()> {
282 serde_json::to_writer_pretty(std::io::stdout(), value)
283 .map_err(|e| CuError::new_with_cause("Failed to write JSON output", e))
284}
285
286fn write_json<T: Serialize + ?Sized>(value: &T) -> CuResult<()> {
287 serde_json::to_writer(std::io::stdout(), value)
288 .map_err(|e| CuError::new_with_cause("Failed to write JSON output", e))
289}
290
291fn build_read_logger(unifiedlog_base: &Path) -> CuResult<UnifiedLoggerRead> {
292 let logger = UnifiedLoggerBuilder::new()
293 .file_base_name(unifiedlog_base)
294 .build()
295 .map_err(|e| CuError::new_with_cause("Failed to create logger", e))?;
296 match logger {
297 UnifiedLogger::Read(dl) => Ok(dl),
298 UnifiedLogger::Write(_) => Err(CuError::from(
299 "Expected read-only unified logger in export CLI",
300 )),
301 }
302}
303
304#[cfg(feature = "mcap")]
308pub fn run_cli<P>() -> CuResult<()>
309where
310 P: CopperListTuple + CuPayloadRawBytes + 'static,
311{
312 #[cfg(feature = "python")]
313 let _ = python::register_copperlist_python_type::<P>();
314
315 run_cli_inner::<P>()
316}
317
318#[cfg(not(feature = "mcap"))]
321pub fn run_cli<P>() -> CuResult<()>
322where
323 P: CopperListTuple + CuPayloadRawBytes + 'static,
324{
325 #[cfg(feature = "python")]
326 let _ = python::register_copperlist_python_type::<P>();
327
328 run_cli_inner::<P>()
329}
330
331fn run_cli_inner<P>() -> CuResult<()>
332where
333 P: CopperListTuple + CuPayloadRawBytes + 'static,
334{
335 run_cli_with_args::<P>(LogReaderCli::parse())
336}
337
338fn run_cli_with_args<P>(args: LogReaderCli) -> CuResult<()>
339where
340 P: CopperListTuple + CuPayloadRawBytes + 'static,
341{
342 let unifiedlog_base = args.unifiedlog_base;
343 #[cfg(feature = "mcap")]
344 if let Command::McapInfo {
345 mcap_file,
346 schemas,
347 sample_messages,
348 } = &args.command
349 {
350 return mcap_info(mcap_file, *schemas, *sample_messages);
351 }
352
353 let runs = runs::discover(&unifiedlog_base)?;
354 if matches!(args.command, Command::ListRuns) {
355 print_runs(&runs);
356 return Ok(());
357 }
358 let run = runs::select(&runs, args.run)?;
359 #[cfg(feature = "self-describing-logs")]
360 let needs_typed_config = !matches!(
361 args.command,
362 Command::Catalog { .. }
363 | Command::ExtractCopperlists {
364 decoder: CopperListDecoder::Catalog,
365 ..
366 }
367 | Command::Fsck { deep: true, .. }
368 );
369 #[cfg(not(feature = "self-describing-logs"))]
370 let needs_typed_config = true;
371 if needs_typed_config && let Some(config) = &run.config {
372 cu29::logcodec::set_effective_config_ron::<P>(config);
373 }
374 let mut dl = run.reader(&unifiedlog_base)?;
375
376 match args.command {
377 Command::ListRuns => unreachable!("run listing handled before selection"),
378 Command::ExtractTextLog { log_index } => {
379 let reader = dl.stream(UnifiedLogType::StructuredLogLine);
380 textlog_dump(reader, &log_index)?;
381 }
382 #[cfg(feature = "self-describing-logs")]
383 Command::Catalog { export_format } => {
384 catalog_cli::dump_catalog(
385 run,
386 &unifiedlog_base,
387 export_format,
388 args.color,
389 &mut std::io::stdout().lock(),
390 )?;
391 }
392 Command::ExtractCopperlists {
393 export_format,
394 #[cfg(feature = "self-describing-logs")]
395 decoder,
396 } => {
397 #[cfg(feature = "self-describing-logs")]
398 if decoder == CopperListDecoder::Catalog {
399 return catalog_cli::extract(
400 &unifiedlog_base,
401 Some(run.index),
402 export_format,
403 &mut std::io::stdout().lock(),
404 );
405 }
406 eprintln!("Extracting copperlists with format: {export_format}");
407 let mut reader = dl.stream(UnifiedLogType::CopperList);
408 let iter = copperlists_reader::<P>(&mut reader);
409
410 match export_format {
411 ExportFormat::Json => {
412 for entry in iter {
413 write_json_pretty(&entry)?;
414 }
415 }
416 ExportFormat::Jsonl => {
417 for entry in iter {
418 write_json(&entry)?;
419 println!();
420 }
421 }
422 ExportFormat::Csv => {
423 let mut first = true;
424 for origin in P::get_all_task_ids() {
425 if !first {
426 print!(", ");
427 } else {
428 print!("id, ");
429 }
430 print!("{origin}_time, {origin}_tov, {origin},");
431 first = false;
432 }
433 println!();
434 for entry in iter {
435 let mut first = true;
436 for msg in entry.cumsgs() {
437 if let Some(payload) = msg.payload() {
438 if !first {
439 print!(", ");
440 } else {
441 print!("{}, ", entry.id);
442 }
443 let metadata = msg.metadata();
444 print!("{}, {}, ", metadata.process_time(), msg.tov());
445 write_json(payload)?; first = false;
447 }
448 }
449 println!();
450 }
451 }
452 }
453 }
454 Command::Fsck {
455 verbose,
456 dump_runtime_lifecycle,
457 deep,
458 } => {
459 if deep {
460 #[cfg(feature = "self-describing-logs")]
461 return catalog_cli::deep_check(
462 run,
463 &unifiedlog_base,
464 verbose,
465 dump_runtime_lifecycle,
466 );
467 #[cfg(not(feature = "self-describing-logs"))]
468 return Err("Deep validation requires the self-describing-logs feature".into());
469 }
470 check::<P>(&mut dl, verbose, dump_runtime_lifecycle)?;
471 }
472 Command::LogStats {
473 output,
474 config,
475 mission,
476 features,
477 } => {
478 run_logstats::<P>(run, dl, output, config, mission, &features)?;
479 }
480 Command::OptimizeSchedule {
481 contract,
482 output,
483 candidates,
484 measurements,
485 } => {
486 run_schedule_optimizer::<P>(run, dl, &contract, &output, candidates, &measurements)?;
487 }
488 #[cfg(feature = "mcap")]
489 Command::ExportMcap {
490 output,
491 progress,
492 quiet,
493 } => {
494 println!("Exporting copperlists to MCAP format: {}", output.display());
495
496 let show_progress = should_show_progress(progress, quiet);
497 let total_bytes = if show_progress {
498 Some(run.copperlist_bytes)
499 } else {
500 None
501 };
502
503 let reader = dl.stream(UnifiedLogType::CopperList);
504
505 let stats = if let Some(total_bytes) = total_bytes {
507 let progress_bar = make_progress_bar(total_bytes);
508 let reader = ProgressReader::new(reader, progress_bar.clone());
509 let result = export_to_mcap_impl::<P>(reader, &output);
510 progress_bar.finish_and_clear();
511 result?
512 } else {
513 export_to_mcap_impl::<P>(reader, &output)?
514 };
515 println!("{stats}");
516 }
517 #[cfg(feature = "mcap")]
518 Command::McapInfo {
519 mcap_file,
520 schemas,
521 sample_messages,
522 } => {
523 mcap_info(&mcap_file, schemas, sample_messages)?;
524 }
525 }
526
527 Ok(())
528}
529
530fn run_logstats<P>(
531 run: &runs::RecordedRun,
532 dl: runs::RunReader,
533 output: PathBuf,
534 config: Option<PathBuf>,
535 mission: Option<String>,
536 features: &[String],
537) -> CuResult<()>
538where
539 P: CopperListTuple + CuPayloadRawBytes,
540{
541 let cfg = if config.is_none() && run.config.is_some() {
542 CuConfig::deserialize_ron(run.config.as_deref().unwrap()).map_err(|e| {
543 CuError::new_with_cause("Failed to read the selected run's configuration", e)
544 })?
545 } else {
546 let config = config.unwrap_or_else(|| PathBuf::from("copperconfig.ron"));
547 let config_path = config
548 .to_str()
549 .ok_or_else(|| CuError::from("Config path is not valid UTF-8"))?;
550 let feature_refs = features.iter().map(String::as_str).collect::<Vec<_>>();
551 cu29::config::read_configuration_with_features(config_path, &feature_refs)
552 .map_err(|e| CuError::new_with_cause("Failed to read configuration", e))?
553 };
554 let mission = resolve_logstats_mission(&cfg, mission, run.missions.first().cloned())?;
555 let reader = dl.stream(UnifiedLogType::CopperList);
556 let stats = compute_logstats::<P>(reader, &cfg, mission.as_deref())?;
557 write_logstats(&stats, &output)
558}
559
560fn run_schedule_optimizer<P>(
561 run: &runs::RecordedRun,
562 dl: runs::RunReader,
563 contract_path: &Path,
564 output: &Path,
565 candidate_count: usize,
566 measurement_args: &[String],
567) -> CuResult<()>
568where
569 P: CopperListTuple + CuPayloadRawBytes,
570{
571 let config_ron = run.config.as_deref().ok_or_else(|| {
572 CuError::from(
573 "The selected run has no recorded configuration; profile-guided scheduling requires a runtime lifecycle record",
574 )
575 })?;
576 let config = CuConfig::deserialize_ron(config_ron).map_err(|error| {
577 CuError::new_with_cause("Failed to read the recorded configuration", error)
578 })?;
579 let mission = resolve_schedule_mission(&config, &run.missions)?;
580 let mission_id = mission
581 .as_deref()
582 .unwrap_or(cu29::config::DEFAULT_MISSION_ID);
583 let contract = cu29::planner::CuContract::read(contract_path)?;
584 let profile = pgs::compute_profile::<P>(
585 dl.stream(UnifiedLogType::CopperList),
586 &config,
587 mission.as_deref(),
588 &contract,
589 )?;
590 let candidates = cu29::planner::propose(&cu29::planner::ProposeRequest {
591 config: &config,
592 mission: mission_id,
593 contract: &contract,
594 profile: &profile,
595 candidates: candidate_count,
596 })?;
597
598 std::fs::create_dir_all(output).map_err(|error| {
599 CuError::new_with_cause("Could not create schedule output directory", error)
600 })?;
601 contract.write(&output.join("contract.ron"))?;
602 profile.write(&output.join("profile.ron"))?;
603
604 let mut predictions = BTreeMap::new();
605 for (index, candidate) in candidates.iter().enumerate() {
606 let name = format!("plan-{}", index + 1);
607 candidate
608 .plan
609 .write(&output.join(format!("{name}.plan.ron")))?;
610 let mut prepared = config.clone();
611 cu29::planner::ExplicitSchedule::new(candidate.plan.clone()).apply(&mut prepared)?;
612 write_text(
613 &output.join(format!("{name}.config.ron")),
614 prepared.serialize_ron()?,
615 "candidate configuration",
616 )?;
617 predictions.insert(name, candidate.prediction.clone());
618 }
619 let predictions_ron =
620 ron::ser::to_string_pretty(&predictions, ron::ser::PrettyConfig::default()).map_err(
621 |error| CuError::new_with_cause("Could not serialize schedule predictions", error),
622 )?;
623 write_text(
624 &output.join("predictions.ron"),
625 predictions_ron,
626 "schedule predictions",
627 )?;
628
629 let mut report = prediction_report(&candidates, mission_id);
630 if !measurement_args.is_empty() {
631 let mut measured = BTreeMap::from([("baseline".to_string(), profile)]);
632 for argument in measurement_args {
633 let (name, log_base) = parse_measurement(argument)?;
634 if measured.contains_key(name) {
635 return Err(CuError::from(format!(
636 "Schedule measurement name '{name}' is repeated"
637 )));
638 }
639 let measured_runs = runs::discover(log_base)?;
640 let measured_run = runs::select(&measured_runs, None)?;
641 let measured_config_ron = measured_run.config.as_deref().ok_or_else(|| {
642 CuError::from(format!(
643 "Measurement '{name}' has no recorded configuration"
644 ))
645 })?;
646 let measured_config =
647 CuConfig::deserialize_ron(measured_config_ron).map_err(|error| {
648 CuError::from(format!(
649 "Failed to read measurement '{name}' configuration: {error}"
650 ))
651 })?;
652 let measured_mission =
653 resolve_schedule_mission(&measured_config, &measured_run.missions)?;
654 let measured_reader = measured_run.reader(log_base)?;
655 let measured_profile = pgs::compute_profile::<P>(
656 measured_reader.stream(UnifiedLogType::CopperList),
657 &measured_config,
658 measured_mission.as_deref(),
659 &contract,
660 )?;
661 measured.insert(name.to_string(), measured_profile);
662 }
663 let score = cu29::planner::CuScoreTable::new(&contract, &predictions, &measured)?;
664 write_text(
665 &output.join("measurements.ron"),
666 score.serialize_ron()?,
667 "schedule measurements",
668 )?;
669 writeln!(report, "\nMeasured ranking:\n{score}")
670 .map_err(|_| CuError::from("Could not format schedule report"))?;
671 if let Some(best) = score.best() {
672 let selected = output.join("selected.plan.ron");
673 if best.candidate == "baseline" {
674 cu29::planner::CuPlan::from_config(&config)?.write(&selected)?;
675 } else if let Some(index) = candidate_index(&best.candidate) {
676 let candidate = candidates.get(index).ok_or_else(|| {
677 CuError::from(format!(
678 "Measurement '{}' does not name an emitted candidate",
679 best.candidate
680 ))
681 })?;
682 candidate.plan.write(&selected)?;
683 }
684 }
685 }
686 write_text(&output.join("report.txt"), &report, "schedule report")?;
687 print!("{report}");
688 println!("Artifacts: {}", output.display());
689 Ok(())
690}
691
692fn write_text(path: &Path, contents: impl AsRef<[u8]>, kind: &str) -> CuResult<()> {
693 std::fs::write(path, contents)
694 .map_err(|error| CuError::from(format!("Could not write {kind}: {error}")))
695}
696
697fn parse_measurement(argument: &str) -> CuResult<(&str, &Path)> {
698 let (name, path) = argument.split_once('=').ok_or_else(|| {
699 CuError::from(format!(
700 "Invalid measurement '{argument}'; expected NAME=LOG_BASE"
701 ))
702 })?;
703 if name.is_empty() || path.is_empty() || name == "baseline" {
704 return Err(CuError::from(format!(
705 "Invalid measurement '{argument}'; use a non-empty name other than 'baseline'"
706 )));
707 }
708 Ok((name, Path::new(path)))
709}
710
711fn candidate_index(name: &str) -> Option<usize> {
712 name.split('/')
713 .next()?
714 .strip_prefix("plan-")?
715 .parse::<usize>()
716 .ok()?
717 .checked_sub(1)
718}
719
720fn prediction_report(candidates: &[cu29::planner::CuCandidate], mission_id: &str) -> String {
721 let mut report = String::from("Predicted schedule ranking\n");
722 for (index, candidate) in candidates.iter().enumerate() {
723 let mission = candidate.plan.missions.get(mission_id);
724 let shape = mission.map_or_else(
725 || "unknown".to_string(),
726 |mission| {
727 format!(
728 "{} CopperList(s)/cycle, {} in flight, {} worker(s)",
729 mission.copperlists_per_cycle,
730 mission.max_in_flight,
731 mission.workers.len()
732 )
733 },
734 );
735 let _ = writeln!(
736 report,
737 "{}. plan-{} score {:?} ({shape})",
738 index + 1,
739 index + 1,
740 candidate.prediction.score
741 );
742 for (chain, prediction) in &candidate.prediction.chains {
743 let _ = writeln!(
744 report,
745 " {chain}: {:.2} ms / {:.2} ms ({:.1}%)",
746 prediction.latency_ns as f64 / 1e6,
747 prediction.deadline_ns as f64 / 1e6,
748 prediction.ratio * 100.0
749 );
750 }
751 for (source, rate) in &candidate.prediction.sources {
752 let _ = writeln!(report, " {source}: {:.1}% delivered", rate * 100.0);
753 }
754 for (worker, prediction) in &candidate.prediction.workers {
755 let response = prediction.response_ns.map_or_else(
756 || "unbounded".to_string(),
757 |ns| format!("{:.2} ms", ns as f64 / 1e6),
758 );
759 let _ = writeln!(
760 report,
761 " {worker}: CPU {}, {:.1}% load, {:.1}% rate, {response} response",
762 prediction.cpu,
763 prediction.load * 100.0,
764 prediction.rate * 100.0,
765 );
766 }
767 }
768 report
769}
770
771fn resolve_schedule_mission(config: &CuConfig, logged: &[String]) -> CuResult<Option<String>> {
772 if logged.len() > 1 {
773 return Err(CuError::from(format!(
774 "Profile-guided scheduling needs a run containing one mission; this run contains {}",
775 logged.join(", ")
776 )));
777 }
778 resolve_logstats_mission(config, None, logged.first().cloned())
779}
780
781fn resolve_logstats_mission(
782 config: &CuConfig,
783 requested: Option<String>,
784 logged: Option<String>,
785) -> CuResult<Option<String>> {
786 let mission = requested.or(logged);
787 let cu29::config::ConfigGraphs::Missions(graphs) = &config.graphs else {
788 return Ok(None);
789 };
790 if let Some(mission) = mission {
791 if !graphs.contains_key(&mission) {
792 return Err(CuError::from(format!(
793 "Mission '{mission}' is absent from the statistics configuration"
794 )));
795 }
796 return Ok(Some(mission));
797 }
798 if graphs.contains_key("default") {
799 return Ok(Some("default".to_string()));
800 }
801 Ok(graphs.keys().min().cloned())
802}
803
804#[cfg(feature = "mcap")]
808fn export_to_mcap_impl<P>(src: impl Read, output: &Path) -> CuResult<McapExportStats>
809where
810 P: CopperListTuple,
811{
812 mcap_export::export_to_mcap::<P, _>(src, output)
813}
814
815#[cfg(feature = "mcap")]
816struct ProgressReader<R> {
817 inner: R,
818 progress: ProgressBar,
819}
820
821#[cfg(feature = "mcap")]
822impl<R> ProgressReader<R> {
823 fn new(inner: R, progress: ProgressBar) -> Self {
824 Self { inner, progress }
825 }
826}
827
828#[cfg(feature = "mcap")]
829impl<R: Read> Read for ProgressReader<R> {
830 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
831 let read = self.inner.read(buf)?;
832 if read > 0 {
833 self.progress.inc(read as u64);
834 }
835 Ok(read)
836 }
837}
838
839#[cfg(feature = "mcap")]
840fn make_progress_bar(total_bytes: u64) -> ProgressBar {
841 let progress_bar = ProgressBar::new(total_bytes);
842 progress_bar.set_draw_target(ProgressDrawTarget::stderr_with_hz(5));
843
844 let style = ProgressStyle::with_template(
845 "[{elapsed_precise}] {bar:40} {bytes}/{total_bytes} ({bytes_per_sec}, ETA {eta})",
846 )
847 .unwrap_or_else(|_| ProgressStyle::default_bar());
848
849 progress_bar.set_style(style.progress_chars("=>-"));
850 progress_bar
851}
852
853#[cfg(feature = "mcap")]
854fn should_show_progress(force_progress: bool, quiet: bool) -> bool {
855 !quiet && (force_progress || std::io::stderr().is_terminal())
856}
857
858fn read_next_entry<T: Decode<()>>(src: &mut impl Read) -> Option<T> {
859 let entry = decode_from_std_read::<T, _, _>(src, standard());
860 match entry {
861 Ok(entry) => Some(entry),
862 Err(DecodeError::UnexpectedEnd { .. }) => None,
863 Err(DecodeError::Io { inner, additional }) => {
864 if inner.kind() == std::io::ErrorKind::UnexpectedEof {
865 None
866 } else {
867 println!("Error {inner:?} additional:{additional}");
868 None
869 }
870 }
871 Err(e) => {
872 println!("Error {e:?}");
873 None
874 }
875 }
876}
877
878pub fn stream_continuity_reader(
880 mut src: impl Read,
881) -> impl Iterator<Item = cu29::continuity::StreamContinuityRecord> {
882 std::iter::from_fn(move || read_next_entry(&mut src))
883}
884
885pub fn copperlists_reader<P: CopperListTuple>(
888 mut src: impl Read,
889) -> impl Iterator<Item = CopperList<P>> {
890 std::iter::from_fn(move || read_next_entry::<CopperList<P>>(&mut src))
891}
892
893pub fn keyframes_reader(mut src: impl Read) -> impl Iterator<Item = KeyFrame> {
895 std::iter::from_fn(move || read_next_entry::<KeyFrame>(&mut src))
896}
897
898pub fn runtime_lifecycle_reader(
900 mut src: impl Read,
901) -> impl Iterator<Item = RuntimeLifecycleRecord> {
902 std::iter::from_fn(move || read_next_entry::<RuntimeLifecycleRecord>(&mut src))
903}
904
905pub fn unified_log_mission(unifiedlog_base: &Path) -> CuResult<Option<String>> {
907 let mut dl = build_read_logger(unifiedlog_base)?;
908 let Some(metadata) = dl.application_metadata()? else {
909 return Ok(None);
910 };
911 loop {
912 let header = dl.raw_skip_section()?;
913 if header.entry_type == UnifiedLogType::LastEntry {
914 return Ok(None);
915 }
916 if header.context.run_id != 0 {
917 return metadata
918 .missions
919 .get(header.context.mission_index as usize)
920 .cloned()
921 .map(Some)
922 .ok_or(CuError::from("Invalid mission index"));
923 }
924 }
925}
926
927pub fn assert_unified_log_mission(unifiedlog_base: &Path, expected_mission: &str) -> CuResult<()> {
929 match unified_log_mission(unifiedlog_base)? {
930 Some(actual_mission) if actual_mission == expected_mission => Ok(()),
931 Some(actual_mission) => Err(CuError::from(format!(
932 "Mission mismatch: expected '{expected_mission}', found '{actual_mission}'"
933 ))),
934 None => Err(CuError::from(format!(
935 "No MissionStarted runtime lifecycle event found while validating expected mission '{expected_mission}'"
936 ))),
937 }
938}
939
940pub fn structlog_reader(mut src: impl Read) -> impl Iterator<Item = CuResult<CuLogEntry>> {
941 std::iter::from_fn(move || {
942 let entry = decode_from_std_read::<CuLogEntry, _, _>(&mut src, standard());
943
944 match entry {
945 Err(DecodeError::UnexpectedEnd { .. }) => None,
946 Err(DecodeError::Io {
947 inner,
948 additional: _,
949 }) => {
950 if inner.kind() == std::io::ErrorKind::UnexpectedEof {
951 None
952 } else {
953 Some(Err(CuError::new_with_cause("Error reading log", inner)))
954 }
955 }
956 Err(e) => Some(Err(CuError::new_with_cause("Error reading log", e))),
957 Ok(entry) => {
958 if entry.msg_index == 0 {
959 None
960 } else {
961 Some(Ok(entry))
962 }
963 }
964 }
965 })
966}
967
968pub fn textlog_dump(src: impl Read, index: &Path) -> CuResult<()> {
973 let all_strings = read_interned_strings(index).map_err(|e| {
974 CuError::new_with_cause(
975 "Failed to read interned strings from index",
976 std::io::Error::other(e),
977 )
978 })?;
979
980 for result in structlog_reader(src) {
981 let entry = result?;
982 match rebuild_logline(&all_strings, &entry) {
983 Ok(line) => println!("{line}"),
984 Err(e) => println!("Failed to rebuild log line: {e:?}"),
985 }
986 }
987
988 Ok(())
989}
990
991#[cfg(feature = "python")]
993mod python {
994 use bincode::config::standard;
995 use bincode::decode_from_std_read;
996 use bincode::error::DecodeError;
997 use cu29::bevy_reflect::{PartialReflect, ReflectRef, VariantType};
998 use cu29::prelude::*;
999 use cu29_intern_strs::read_interned_strings;
1000 use pyo3::exceptions::{PyIOError, PyRuntimeError};
1001 use pyo3::prelude::*;
1002 use pyo3::types::{PyDelta, PyDict, PyList};
1003 use std::io::Read;
1004 use std::path::Path;
1005 use std::sync::OnceLock;
1006
1007 type CopperListDecodeFn =
1008 for<'py> fn(&mut Box<dyn Read + Send + Sync>, Python<'py>) -> Option<PyResult<Py<PyAny>>>;
1009 static COPPERLIST_DECODER: OnceLock<CopperListDecodeFn> = OnceLock::new();
1010
1011 #[pyclass]
1013 pub struct PyLogIterator {
1014 reader: Box<dyn Read + Send + Sync>,
1015 }
1016
1017 #[pyclass]
1019 pub struct PyCopperListIterator {
1020 reader: Box<dyn Read + Send + Sync>,
1021 decode_next: CopperListDecodeFn,
1022 }
1023
1024 #[pyclass]
1026 pub struct PyRuntimeLifecycleIterator {
1027 reader: Box<dyn Read + Send + Sync>,
1028 }
1029
1030 #[pyclass(get_all)]
1032 pub struct PyUnitValue {
1033 pub value: f64,
1034 pub unit: String,
1035 }
1036
1037 pub fn register_copperlist_python_type<P>() -> CuResult<()>
1042 where
1043 P: CopperListTuple,
1044 {
1045 if COPPERLIST_DECODER.get().is_none() {
1046 COPPERLIST_DECODER
1047 .set(decode_next_copperlist::<P>)
1048 .map_err(|_| CuError::from("Failed to register CopperList Python decoder"))?;
1049 }
1050 Ok(())
1051 }
1052 #[pymethods]
1053 impl PyLogIterator {
1054 fn __iter__(slf: PyRefMut<Self>) -> PyRefMut<Self> {
1055 slf
1056 }
1057
1058 fn __next__(mut slf: PyRefMut<Self>) -> Option<PyResult<PyCuLogEntry>> {
1059 match decode_from_std_read::<CuLogEntry, _, _>(&mut slf.reader, standard()) {
1060 Ok(entry) => {
1061 if entry.msg_index == 0 {
1062 None
1063 } else {
1064 Some(Ok(PyCuLogEntry { inner: entry }))
1065 }
1066 }
1067 Err(DecodeError::UnexpectedEnd { .. }) => None,
1068 Err(DecodeError::Io { inner, .. })
1069 if inner.kind() == std::io::ErrorKind::UnexpectedEof =>
1070 {
1071 None
1072 }
1073 Err(e) => Some(Err(PyIOError::new_err(e.to_string()))),
1074 }
1075 }
1076 }
1077
1078 #[pymethods]
1079 impl PyCopperListIterator {
1080 fn __iter__(slf: PyRefMut<Self>) -> PyRefMut<Self> {
1081 slf
1082 }
1083
1084 fn __next__(mut slf: PyRefMut<Self>, py: Python<'_>) -> Option<PyResult<Py<PyAny>>> {
1085 (slf.decode_next)(&mut slf.reader, py)
1086 }
1087 }
1088
1089 #[pymethods]
1090 impl PyRuntimeLifecycleIterator {
1091 fn __iter__(slf: PyRefMut<Self>) -> PyRefMut<Self> {
1092 slf
1093 }
1094
1095 fn __next__(mut slf: PyRefMut<Self>, py: Python<'_>) -> Option<PyResult<Py<PyAny>>> {
1096 let entry = super::read_next_entry::<RuntimeLifecycleRecord>(&mut slf.reader)?;
1097 Some(runtime_lifecycle_record_to_py(&entry, py))
1098 }
1099 }
1100 #[pyfunction]
1106 pub fn struct_log_iterator_bare(
1107 bare_struct_src_path: &str,
1108 index_path: &str,
1109 ) -> PyResult<(PyLogIterator, Vec<String>)> {
1110 let file = std::fs::File::open(bare_struct_src_path)
1111 .map_err(|e| PyIOError::new_err(e.to_string()))?;
1112 let all_strings = read_interned_strings(Path::new(index_path))
1113 .map_err(|e| PyIOError::new_err(e.to_string()))?;
1114 Ok((
1115 PyLogIterator {
1116 reader: Box::new(file),
1117 },
1118 all_strings,
1119 ))
1120 }
1121 #[pyfunction]
1126 pub fn struct_log_iterator_unified(
1127 unified_src_path: &str,
1128 index_path: &str,
1129 ) -> PyResult<(PyLogIterator, Vec<String>)> {
1130 let all_strings = read_interned_strings(Path::new(index_path))
1131 .map_err(|e| PyIOError::new_err(e.to_string()))?;
1132
1133 let logger = UnifiedLoggerBuilder::new()
1134 .file_base_name(Path::new(unified_src_path))
1135 .build()
1136 .map_err(|e| PyIOError::new_err(e.to_string()))?;
1137 let dl = match logger {
1138 UnifiedLogger::Read(dl) => dl,
1139 UnifiedLogger::Write(_) => {
1140 return Err(PyIOError::new_err(
1141 "Expected read-only unified logger for Python export",
1142 ));
1143 }
1144 };
1145
1146 let reader = UnifiedLoggerIOReader::new(dl, UnifiedLogType::StructuredLogLine);
1147 Ok((
1148 PyLogIterator {
1149 reader: Box::new(reader),
1150 },
1151 all_strings,
1152 ))
1153 }
1154
1155 #[pyfunction]
1160 pub fn copperlist_iterator_unified(unified_src_path: &str) -> PyResult<PyCopperListIterator> {
1161 let decode_next = *COPPERLIST_DECODER.get().ok_or_else(|| {
1162 PyRuntimeError::new_err(
1163 "CopperList decoder is not registered. \
1164Call register_copperlist_python_type::<P>() from Rust before using this function.",
1165 )
1166 })?;
1167
1168 let logger = UnifiedLoggerBuilder::new()
1169 .file_base_name(Path::new(unified_src_path))
1170 .build()
1171 .map_err(|e| PyIOError::new_err(e.to_string()))?;
1172 let dl = match logger {
1173 UnifiedLogger::Read(dl) => dl,
1174 UnifiedLogger::Write(_) => {
1175 return Err(PyIOError::new_err(
1176 "Expected read-only unified logger for Python export",
1177 ));
1178 }
1179 };
1180
1181 let reader = UnifiedLoggerIOReader::new(dl, UnifiedLogType::CopperList);
1182 Ok(PyCopperListIterator {
1183 reader: Box::new(reader),
1184 decode_next,
1185 })
1186 }
1187
1188 #[pyfunction]
1190 pub fn runtime_lifecycle_iterator_unified(
1191 unified_src_path: &str,
1192 ) -> PyResult<PyRuntimeLifecycleIterator> {
1193 let logger = UnifiedLoggerBuilder::new()
1194 .file_base_name(Path::new(unified_src_path))
1195 .build()
1196 .map_err(|e| PyIOError::new_err(e.to_string()))?;
1197 let dl = match logger {
1198 UnifiedLogger::Read(dl) => dl,
1199 UnifiedLogger::Write(_) => {
1200 return Err(PyIOError::new_err(
1201 "Expected read-only unified logger for Python export",
1202 ));
1203 }
1204 };
1205
1206 let reader = UnifiedLoggerIOReader::new(dl, UnifiedLogType::RuntimeLifecycle);
1207 Ok(PyRuntimeLifecycleIterator {
1208 reader: Box::new(reader),
1209 })
1210 }
1211 #[pyclass]
1213 pub struct PyCuLogEntry {
1214 pub inner: CuLogEntry,
1215 }
1216
1217 #[pymethods]
1218 impl PyCuLogEntry {
1219 pub fn ts<'a>(&self, py: Python<'a>) -> PyResult<Bound<'a, PyDelta>> {
1221 let nanoseconds: u64 = self.inner.time.into();
1222
1223 let days = (nanoseconds / 86_400_000_000_000) as i32;
1225 let seconds = (nanoseconds / 1_000_000_000) as i32;
1226 let microseconds = ((nanoseconds % 1_000_000_000) / 1_000) as i32;
1227
1228 PyDelta::new(py, days, seconds, microseconds, false)
1229 }
1230
1231 pub fn msg_index(&self) -> u32 {
1233 self.inner.msg_index
1234 }
1235
1236 pub fn culistid(&self) -> Option<u64> {
1238 self.inner.origin.culistid
1239 }
1240
1241 pub fn component_id(&self) -> Option<u32> {
1243 self.inner.origin.component_id
1244 }
1245
1246 pub fn task_index(&self) -> Option<u32> {
1248 self.inner.origin.task_index
1249 }
1250
1251 pub fn paramname_indexes(&self) -> Vec<u32> {
1253 self.inner.paramname_indexes.iter().copied().collect()
1254 }
1255
1256 pub fn params(&self, py: Python<'_>) -> PyResult<Vec<Py<PyAny>>> {
1258 self.inner
1259 .params
1260 .iter()
1261 .map(|value| value_to_py(value, py))
1262 .collect()
1263 }
1264 }
1265
1266 #[pymodule(name = "libcu29_export")]
1268 fn cu29_export(m: &Bound<'_, PyModule>) -> PyResult<()> {
1269 #[cfg(feature = "self-describing-logs")]
1270 crate::catalog_python::add_functions(m)?;
1271 m.add_class::<PyCuLogEntry>()?;
1272 m.add_class::<PyLogIterator>()?;
1273 m.add_class::<PyCopperListIterator>()?;
1274 m.add_class::<PyRuntimeLifecycleIterator>()?;
1275 m.add_class::<PyUnitValue>()?;
1276 m.add_function(wrap_pyfunction!(struct_log_iterator_bare, m)?)?;
1277 m.add_function(wrap_pyfunction!(struct_log_iterator_unified, m)?)?;
1278 m.add_function(wrap_pyfunction!(copperlist_iterator_unified, m)?)?;
1279 m.add_function(wrap_pyfunction!(runtime_lifecycle_iterator_unified, m)?)?;
1280 Ok(())
1281 }
1282
1283 fn decode_next_copperlist<P>(
1284 reader: &mut Box<dyn Read + Send + Sync>,
1285 py: Python<'_>,
1286 ) -> Option<PyResult<Py<PyAny>>>
1287 where
1288 P: CopperListTuple,
1289 {
1290 let entry = super::read_next_entry::<CopperList<P>>(reader)?;
1291 Some(copperlist_to_py::<P>(&entry, py))
1292 }
1293
1294 fn copperlist_to_py<P>(entry: &CopperList<P>, py: Python<'_>) -> PyResult<Py<PyAny>>
1295 where
1296 P: CopperListTuple,
1297 {
1298 let task_ids = P::get_all_task_ids();
1299 let root = PyDict::new(py);
1300 root.set_item("id", entry.id)?;
1301
1302 let mut messages: Vec<Py<PyAny>> = Vec::new();
1303 for (idx, msg) in entry.cumsgs().into_iter().enumerate() {
1304 let message = PyDict::new(py);
1305 message.set_item("task_id", task_ids.get(idx).copied().unwrap_or("unknown"))?;
1306 message.set_item("tov", tov_to_py(msg.tov(), py)?)?;
1307 message.set_item("metadata", metadata_to_py(msg.metadata(), py)?)?;
1308 match msg.payload_reflect() {
1309 Some(payload) => message.set_item(
1310 "payload",
1311 partial_reflect_to_py(payload.as_partial_reflect(), py)?,
1312 )?,
1313 None => message.set_item("payload", py.None())?,
1314 }
1315 messages.push(dict_to_namespace(message, py)?);
1316 }
1317
1318 root.set_item("messages", PyList::new(py, messages)?)?;
1319 dict_to_namespace(root, py)
1320 }
1321
1322 fn runtime_lifecycle_record_to_py(
1323 entry: &RuntimeLifecycleRecord,
1324 py: Python<'_>,
1325 ) -> PyResult<Py<PyAny>> {
1326 let root = PyDict::new(py);
1327 root.set_item("timestamp_ns", entry.timestamp.as_nanos())?;
1328 root.set_item("event", runtime_lifecycle_event_to_py(&entry.event, py)?)?;
1329 dict_to_namespace(root, py)
1330 }
1331
1332 fn runtime_lifecycle_event_to_py(
1333 event: &RuntimeLifecycleEvent,
1334 py: Python<'_>,
1335 ) -> PyResult<Py<PyAny>> {
1336 let root = PyDict::new(py);
1337 match event {
1338 RuntimeLifecycleEvent::Instantiated { config_source } => {
1339 root.set_item("kind", "instantiated")?;
1340 root.set_item("config_source", runtime_config_source_to_py(config_source))?;
1341 }
1342 RuntimeLifecycleEvent::MissionStarted => {
1343 root.set_item("kind", "mission_started")?;
1344 }
1345 RuntimeLifecycleEvent::MissionStopped { reason } => {
1346 root.set_item("kind", "mission_stopped")?;
1347 root.set_item("reason", format!("{reason:?}"))?;
1348 }
1349 RuntimeLifecycleEvent::LifecycleFailed { operation, error } => {
1350 root.set_item("kind", "lifecycle_failed")?;
1351 root.set_item("operation", format!("{operation:?}"))?;
1352 root.set_item("error", error)?;
1353 }
1354 RuntimeLifecycleEvent::Panic {
1355 message,
1356 file,
1357 line,
1358 column,
1359 } => {
1360 root.set_item("kind", "panic")?;
1361 root.set_item("message", message)?;
1362 root.set_item("file", file)?;
1363 root.set_item("line", line)?;
1364 root.set_item("column", column)?;
1365 }
1366 RuntimeLifecycleEvent::ShutdownCompleted => {
1367 root.set_item("kind", "shutdown_completed")?;
1368 }
1369 #[allow(unreachable_patterns)] event => {
1371 if let Some(record) = event.clock_sync_record() {
1372 root.set_item("kind", "clock_sync")?;
1373 root.set_item("culistid", record.culistid)?;
1374 let snapshot = serde_json::to_string(&record.snapshot).map_err(|error| {
1375 pyo3::exceptions::PyValueError::new_err(error.to_string())
1376 })?;
1377 root.set_item(
1378 "snapshot",
1379 py.import("json")?.getattr("loads")?.call1((snapshot,))?,
1380 )?;
1381 }
1382 }
1383 }
1384
1385 dict_to_namespace(root, py)
1386 }
1387
1388 fn runtime_config_source_to_py(source: &RuntimeLifecycleConfigSource) -> &'static str {
1389 match source {
1390 RuntimeLifecycleConfigSource::ProgrammaticOverride => "programmatic_override",
1391 RuntimeLifecycleConfigSource::ExternalFile => "external_file",
1392 RuntimeLifecycleConfigSource::BundledDefault => "bundled_default",
1393 }
1394 }
1395
1396 fn metadata_to_py(metadata: &dyn CuMsgMetadataTrait, py: Python<'_>) -> PyResult<Py<PyAny>> {
1397 let process = metadata.process_time();
1398 let start: Option<CuTime> = process.start.into();
1399 let end: Option<CuTime> = process.end.into();
1400
1401 let process_time = PyDict::new(py);
1402 process_time.set_item("start_ns", start.map(|t| t.as_nanos()))?;
1403 process_time.set_item("end_ns", end.map(|t| t.as_nanos()))?;
1404
1405 let metadata_py = PyDict::new(py);
1406 metadata_py.set_item("process_time", dict_to_namespace(process_time, py)?)?;
1407 metadata_py.set_item("status_txt", metadata.status_txt().0.to_string())?;
1408 if let Some(origin) = metadata.origin() {
1409 let origin_py = PyDict::new(py);
1410 origin_py.set_item("subsystem_code", origin.subsystem_code)?;
1411 origin_py.set_item("instance_id", origin.instance_id)?;
1412 origin_py.set_item("cl_id", origin.cl_id)?;
1413 metadata_py.set_item("origin", dict_to_namespace(origin_py, py)?)?;
1414 } else {
1415 metadata_py.set_item("origin", py.None())?;
1416 }
1417 dict_to_namespace(metadata_py, py)
1418 }
1419
1420 fn tov_to_py(tov: Tov, py: Python<'_>) -> PyResult<Py<PyAny>> {
1421 let tov_py = PyDict::new(py);
1422 match tov {
1423 Tov::None => {
1424 tov_py.set_item("kind", "none")?;
1425 }
1426 Tov::Time(t) => {
1427 tov_py.set_item("kind", "time")?;
1428 tov_py.set_item("time_ns", t.as_nanos())?;
1429 }
1430 Tov::Range(r) => {
1431 tov_py.set_item("kind", "range")?;
1432 tov_py.set_item("start_ns", r.start.as_nanos())?;
1433 tov_py.set_item("end_ns", r.end.as_nanos())?;
1434 }
1435 }
1436 dict_to_namespace(tov_py, py)
1437 }
1438
1439 fn partial_reflect_to_py(value: &dyn PartialReflect, py: Python<'_>) -> PyResult<Py<PyAny>> {
1440 #[allow(unreachable_patterns)]
1441 match value.reflect_ref() {
1442 ReflectRef::Struct(s) => struct_to_py(s, py),
1443 ReflectRef::TupleStruct(ts) => tuple_struct_to_py(ts, py),
1444 ReflectRef::Tuple(t) => tuple_to_py(t, py),
1445 ReflectRef::List(list) => list_to_py(list, py),
1446 ReflectRef::Array(array) => array_to_py(array, py),
1447 ReflectRef::Map(map) => map_to_py(map, py),
1448 ReflectRef::Set(set) => set_to_py(set, py),
1449 ReflectRef::Enum(e) => enum_to_py(e, py),
1450 ReflectRef::Opaque(opaque) => opaque_to_py(opaque, py),
1451 _ => Ok(py.None()),
1452 }
1453 }
1454
1455 fn struct_to_py(value: &dyn cu29::bevy_reflect::Struct, py: Python<'_>) -> PyResult<Py<PyAny>> {
1456 let dict = PyDict::new(py);
1457 for idx in 0..value.field_len() {
1458 if let Some(field) = value.field_at(idx) {
1459 let name = value
1460 .name_at(idx)
1461 .map(str::to_owned)
1462 .unwrap_or_else(|| format!("field_{idx}"));
1463 dict.set_item(name, partial_reflect_to_py(field, py)?)?;
1464 }
1465 }
1466
1467 if let Some(unit) = unit_abbrev_for_type_path(value.reflect_type_path())
1468 && let Some(raw_value) = dict.get_item("value")?
1469 {
1470 if let Ok(v) = raw_value.extract::<f64>() {
1471 let unit_value = PyUnitValue {
1472 value: v,
1473 unit: unit.to_string(),
1474 };
1475 return Ok(Py::new(py, unit_value)?.into());
1476 }
1477 if let Ok(v) = raw_value.extract::<f32>() {
1478 let unit_value = PyUnitValue {
1479 value: v as f64,
1480 unit: unit.to_string(),
1481 };
1482 return Ok(Py::new(py, unit_value)?.into());
1483 }
1484 }
1485
1486 dict_to_namespace(dict, py)
1487 }
1488
1489 fn tuple_struct_to_py(
1490 value: &dyn cu29::bevy_reflect::TupleStruct,
1491 py: Python<'_>,
1492 ) -> PyResult<Py<PyAny>> {
1493 let mut fields = Vec::with_capacity(value.field_len());
1494 for idx in 0..value.field_len() {
1495 if let Some(field) = value.field(idx) {
1496 fields.push(partial_reflect_to_py(field, py)?);
1497 } else {
1498 fields.push(py.None());
1499 }
1500 }
1501 Ok(PyList::new(py, fields)?.into_pyobject(py)?.into())
1502 }
1503
1504 fn tuple_to_py(value: &dyn cu29::bevy_reflect::Tuple, py: Python<'_>) -> PyResult<Py<PyAny>> {
1505 let mut fields = Vec::with_capacity(value.field_len());
1506 for idx in 0..value.field_len() {
1507 if let Some(field) = value.field(idx) {
1508 fields.push(partial_reflect_to_py(field, py)?);
1509 } else {
1510 fields.push(py.None());
1511 }
1512 }
1513 Ok(PyList::new(py, fields)?.into_pyobject(py)?.into())
1514 }
1515
1516 fn list_to_py(value: &dyn cu29::bevy_reflect::List, py: Python<'_>) -> PyResult<Py<PyAny>> {
1517 let mut items = Vec::with_capacity(value.len());
1518 for item in value.iter() {
1519 items.push(partial_reflect_to_py(item, py)?);
1520 }
1521 Ok(PyList::new(py, items)?.into_pyobject(py)?.into())
1522 }
1523
1524 fn array_to_py(value: &dyn cu29::bevy_reflect::Array, py: Python<'_>) -> PyResult<Py<PyAny>> {
1525 let mut items = Vec::with_capacity(value.len());
1526 for item in value.iter() {
1527 items.push(partial_reflect_to_py(item, py)?);
1528 }
1529 Ok(PyList::new(py, items)?.into_pyobject(py)?.into())
1530 }
1531
1532 fn map_to_py(value: &dyn cu29::bevy_reflect::Map, py: Python<'_>) -> PyResult<Py<PyAny>> {
1533 let dict = PyDict::new(py);
1534 for (key, val) in value.iter() {
1535 let key_str = reflect_key_to_string(key);
1536 dict.set_item(key_str, partial_reflect_to_py(val, py)?)?;
1537 }
1538 Ok(dict.into_pyobject(py)?.into())
1539 }
1540
1541 fn set_to_py(value: &dyn cu29::bevy_reflect::Set, py: Python<'_>) -> PyResult<Py<PyAny>> {
1542 let mut items = Vec::with_capacity(value.len());
1543 for item in value.iter() {
1544 items.push(partial_reflect_to_py(item, py)?);
1545 }
1546 Ok(PyList::new(py, items)?.into_pyobject(py)?.into())
1547 }
1548
1549 fn enum_to_py(value: &dyn cu29::bevy_reflect::Enum, py: Python<'_>) -> PyResult<Py<PyAny>> {
1550 let dict = PyDict::new(py);
1551 dict.set_item("variant", value.variant_name())?;
1552
1553 match value.variant_type() {
1554 VariantType::Unit => {}
1555 VariantType::Tuple => {
1556 let mut fields = Vec::with_capacity(value.field_len());
1557 for idx in 0..value.field_len() {
1558 if let Some(field) = value.field_at(idx) {
1559 fields.push(partial_reflect_to_py(field, py)?);
1560 } else {
1561 fields.push(py.None());
1562 }
1563 }
1564 dict.set_item("fields", PyList::new(py, fields)?)?;
1565 }
1566 VariantType::Struct => {
1567 let fields = PyDict::new(py);
1568 for idx in 0..value.field_len() {
1569 if let Some(field) = value.field_at(idx) {
1570 let name = value
1571 .name_at(idx)
1572 .map(str::to_owned)
1573 .unwrap_or_else(|| format!("field_{idx}"));
1574 fields.set_item(name, partial_reflect_to_py(field, py)?)?;
1575 }
1576 }
1577 dict.set_item("fields", fields)?;
1578 }
1579 }
1580
1581 dict_to_namespace(dict, py)
1582 }
1583
1584 fn dict_to_namespace(dict: Bound<'_, PyDict>, py: Python<'_>) -> PyResult<Py<PyAny>> {
1585 let types = py.import("types")?;
1586 let namespace_ctor = types.getattr("SimpleNamespace")?;
1587 let namespace = namespace_ctor.call((), Some(&dict))?;
1588 Ok(namespace.into())
1589 }
1590
1591 fn reflect_key_to_string(value: &dyn PartialReflect) -> String {
1592 if let Some(v) = value.try_downcast_ref::<String>() {
1593 return v.clone();
1594 }
1595 if let Some(v) = value.try_downcast_ref::<&'static str>() {
1596 return (*v).to_string();
1597 }
1598 if let Some(v) = value.try_downcast_ref::<char>() {
1599 return v.to_string();
1600 }
1601 if let Some(v) = value.try_downcast_ref::<bool>() {
1602 return v.to_string();
1603 }
1604 if let Some(v) = value.try_downcast_ref::<u64>() {
1605 return v.to_string();
1606 }
1607 if let Some(v) = value.try_downcast_ref::<i64>() {
1608 return v.to_string();
1609 }
1610 if let Some(v) = value.try_downcast_ref::<usize>() {
1611 return v.to_string();
1612 }
1613 if let Some(v) = value.try_downcast_ref::<isize>() {
1614 return v.to_string();
1615 }
1616 format!("{value:?}")
1617 }
1618
1619 fn unit_abbrev_for_type_path(type_path: &str) -> Option<&'static str> {
1620 match type_path.rsplit("::").next()? {
1621 "Acceleration" => Some("m/s^2"),
1622 "Angle" => Some("rad"),
1623 "AngularVelocity" => Some("rad/s"),
1624 "ElectricPotential" => Some("V"),
1625 "Length" => Some("m"),
1626 "MagneticFluxDensity" => Some("T"),
1627 "Pressure" => Some("Pa"),
1628 "Ratio" => Some("1"),
1629 "ThermodynamicTemperature" => Some("K"),
1630 "Time" => Some("s"),
1631 "Velocity" => Some("m/s"),
1632 _ => None,
1633 }
1634 }
1635
1636 fn opaque_to_py(value: &dyn PartialReflect, py: Python<'_>) -> PyResult<Py<PyAny>> {
1637 macro_rules! downcast_copy {
1638 ($ty:ty) => {
1639 if let Some(v) = value.try_downcast_ref::<$ty>() {
1640 return Ok(v.into_pyobject(py)?.to_owned().into());
1641 }
1642 };
1643 }
1644
1645 downcast_copy!(bool);
1646 downcast_copy!(u8);
1647 downcast_copy!(u16);
1648 downcast_copy!(u32);
1649 downcast_copy!(u64);
1650 downcast_copy!(u128);
1651 downcast_copy!(usize);
1652 downcast_copy!(i8);
1653 downcast_copy!(i16);
1654 downcast_copy!(i32);
1655 downcast_copy!(i64);
1656 downcast_copy!(i128);
1657 downcast_copy!(isize);
1658 downcast_copy!(f32);
1659 downcast_copy!(f64);
1660 downcast_copy!(char);
1661
1662 if let Some(v) = value.try_downcast_ref::<String>() {
1663 return Ok(v.into_pyobject(py)?.into());
1664 }
1665 if let Some(v) = value.try_downcast_ref::<&'static str>() {
1666 return Ok(v.into_pyobject(py)?.into());
1667 }
1668 if let Some(v) = value.try_downcast_ref::<Vec<u8>>() {
1669 return Ok(v.into_pyobject(py)?.into());
1670 }
1671
1672 let fallback = format!("{value:?}");
1673 Ok(fallback.into_pyobject(py)?.into())
1674 }
1675 pub(super) fn value_to_py(value: &cu29::prelude::Value, py: Python<'_>) -> PyResult<Py<PyAny>> {
1676 match value {
1677 Value::String(s) => Ok(s.into_pyobject(py)?.into()),
1678 Value::U64(u) => Ok(u.into_pyobject(py)?.into()),
1679 Value::U128(u) => Ok(u.into_pyobject(py)?.into()),
1680 Value::I64(i) => Ok(i.into_pyobject(py)?.into()),
1681 Value::I128(i) => Ok(i.into_pyobject(py)?.into()),
1682 Value::F64(f) => Ok(f.into_pyobject(py)?.into()),
1683 Value::Bool(b) => Ok(b.into_pyobject(py)?.to_owned().into()),
1684 Value::CuTime(t) => Ok(t.0.into_pyobject(py)?.into()),
1685 Value::Bytes(b) => Ok(b.into_pyobject(py)?.into()),
1686 Value::Char(c) => Ok(c.into_pyobject(py)?.into()),
1687 Value::I8(i) => Ok(i.into_pyobject(py)?.into()),
1688 Value::U8(u) => Ok(u.into_pyobject(py)?.into()),
1689 Value::I16(i) => Ok(i.into_pyobject(py)?.into()),
1690 Value::U16(u) => Ok(u.into_pyobject(py)?.into()),
1691 Value::I32(i) => Ok(i.into_pyobject(py)?.into()),
1692 Value::U32(u) => Ok(u.into_pyobject(py)?.into()),
1693 Value::Map(m) => {
1694 let dict = PyDict::new(py);
1695 for (k, v) in m.iter() {
1696 dict.set_item(value_to_py(k, py)?, value_to_py(v, py)?)?;
1697 }
1698 Ok(dict.into_pyobject(py)?.into())
1699 }
1700 Value::F32(f) => Ok(f.into_pyobject(py)?.into()),
1701 Value::Option(o) => match o.as_ref() {
1702 Some(value) => value_to_py(value, py),
1703 None => Ok(py.None()),
1704 },
1705 Value::Unit => Ok(py.None()),
1706 Value::Newtype(v) => value_to_py(v, py),
1707 Value::Seq(s) => {
1708 let items: Vec<Py<PyAny>> = s
1709 .iter()
1710 .map(|value| value_to_py(value, py))
1711 .collect::<PyResult<_>>()?;
1712 let list = PyList::new(py, items)?;
1713 Ok(list.into_pyobject(py)?.into())
1714 }
1715 }
1716 }
1717
1718 #[cfg(test)]
1719 mod tests {
1720 use super::*;
1721
1722 #[derive(Debug, Default, bincode::Encode, bincode::Decode, serde::Serialize)]
1723 struct EmptyMessages;
1724
1725 impl ErasedCuStampedDataSet for EmptyMessages {
1726 fn cumsgs(&self) -> Vec<&dyn ErasedCuStampedData> {
1727 Vec::new()
1728 }
1729 }
1730
1731 impl MatchingTasks for EmptyMessages {
1732 fn get_all_task_ids() -> &'static [&'static str] {
1733 &[]
1734 }
1735 }
1736
1737 #[test]
1738 fn copperlist_python_export_omits_runtime_state() {
1739 Python::initialize();
1740 Python::attach(|py| {
1741 let list = CopperList::new(7, EmptyMessages);
1742 let value = copperlist_to_py(&list, py).unwrap();
1743 assert!(!value.bind(py).hasattr("state").unwrap());
1744 assert_eq!(
1745 value
1746 .bind(py)
1747 .getattr("id")
1748 .unwrap()
1749 .extract::<u64>()
1750 .unwrap(),
1751 7
1752 );
1753 });
1754 }
1755
1756 #[test]
1757 fn value_to_py_preserves_128_bit_integers() {
1758 Python::initialize();
1759 Python::attach(|py| {
1760 let u128_value = u128::from(u64::MAX) + 99;
1761 let u128_py = value_to_py(&Value::U128(u128_value), py).unwrap();
1762 assert_eq!(u128_py.bind(py).extract::<u128>().unwrap(), u128_value);
1763
1764 let i128_value = i128::from(i64::MIN) - 99;
1765 let i128_py = value_to_py(&Value::I128(i128_value), py).unwrap();
1766 assert_eq!(i128_py.bind(py).extract::<i128>().unwrap(), i128_value);
1767 });
1768 }
1769 }
1770}
1771
1772#[cfg(test)]
1773mod tests {
1774 use super::*;
1775 use bincode::{Decode, Encode, encode_into_slice};
1776 use serde::Deserialize;
1777 use std::env;
1778 use std::fs;
1779 use std::io::Cursor;
1780 use std::path::PathBuf;
1781 use std::sync::{Arc, Mutex};
1782 use tempfile::{TempDir, tempdir};
1783
1784 fn copy_stringindex_to_temp(tmpdir: &TempDir) -> PathBuf {
1785 let fake_out_dir = tmpdir.path().join("build").join("out").join("dir");
1787 fs::create_dir_all(&fake_out_dir).unwrap();
1788 unsafe {
1790 env::set_var("LOG_INDEX_DIR", &fake_out_dir);
1791 }
1792
1793 let _ = cu29_intern_strs::intern_string("unused to start counter");
1795 let _ = cu29_intern_strs::intern_string("Just a String {}");
1796 let _ = cu29_intern_strs::intern_string("Just a String (low level) {}");
1797 let _ = cu29_intern_strs::intern_string("Just a String (end to end) {}");
1798
1799 let index_dir = cu29_intern_strs::default_log_index_dir();
1800 cu29_intern_strs::read_interned_strings(&index_dir).unwrap();
1801 index_dir
1802 }
1803
1804 #[test]
1805 fn test_extract_low_level_cu29_log() {
1806 let temp_dir = TempDir::new().unwrap();
1807 let temp_path = copy_stringindex_to_temp(&temp_dir);
1808 let entry = CuLogEntry::new(3, CuLogLevel::Info);
1809 let bytes = bincode::encode_to_vec(&entry, standard()).unwrap();
1810 let reader = Cursor::new(bytes.as_slice());
1811 textlog_dump(reader, temp_path.as_path()).unwrap();
1812 }
1813
1814 #[test]
1815 fn end_to_end_datalogger_and_structlog_test() {
1816 let dir = tempdir().expect("Failed to create temp dir");
1817 let path = dir
1818 .path()
1819 .join("end_to_end_datalogger_and_structlog_test.copper");
1820 {
1821 let UnifiedLogger::Write(logger) = UnifiedLoggerBuilder::new()
1823 .write(true)
1824 .create(true)
1825 .file_base_name(&path)
1826 .preallocated_size(100000)
1827 .build()
1828 .expect("Failed to create logger")
1829 else {
1830 panic!("Failed to create logger")
1831 };
1832 let data_logger = Arc::new(Mutex::new(logger));
1833 let stream = stream_write(data_logger.clone(), UnifiedLogType::StructuredLogLine, 1024)
1834 .expect("Failed to create stream");
1835 let rt = LoggerRuntime::init(RobotClock::default(), stream, None::<NullLog>);
1836
1837 let mut entry = CuLogEntry::new(4, CuLogLevel::Info); entry.add_param(0, Value::String("Parameter for the log line".into()));
1839 log(&mut entry).expect("Failed to log");
1840 let mut entry = CuLogEntry::new(2, CuLogLevel::Info); entry.add_param(0, Value::String("Parameter for the log line".into()));
1842 log(&mut entry).expect("Failed to log");
1843
1844 drop(rt);
1846 }
1847 let UnifiedLogger::Read(logger) = UnifiedLoggerBuilder::new()
1849 .file_base_name(
1850 &dir.path()
1851 .join("end_to_end_datalogger_and_structlog_test.copper"),
1852 )
1853 .build()
1854 .expect("Failed to create logger")
1855 else {
1856 panic!("Failed to create logger")
1857 };
1858 let reader = UnifiedLoggerIOReader::new(logger, UnifiedLogType::StructuredLogLine);
1859 let temp_dir = TempDir::new().unwrap();
1860 textlog_dump(
1861 reader,
1862 Path::new(copy_stringindex_to_temp(&temp_dir).as_path()),
1863 )
1864 .expect("Failed to dump log");
1865 }
1866
1867 #[derive(Debug, PartialEq, Clone, Copy, Serialize, Deserialize, Encode, Decode, Default)]
1869 struct MyMsgs((u8, i32, f32));
1870
1871 impl ErasedCuStampedDataSet for MyMsgs {
1872 fn cumsgs(&self) -> Vec<&dyn ErasedCuStampedData> {
1873 Vec::new()
1874 }
1875 }
1876
1877 impl MatchingTasks for MyMsgs {
1878 fn get_all_task_ids() -> &'static [&'static str] {
1879 &[]
1880 }
1881 }
1882
1883 #[test]
1885 fn test_copperlists_dump() {
1886 let mut data = vec![0u8; 10000];
1887 let mypls: [MyMsgs; 4] = [
1888 MyMsgs((1, 2, 3.0)),
1889 MyMsgs((2, 3, 4.0)),
1890 MyMsgs((3, 4, 5.0)),
1891 MyMsgs((4, 5, 6.0)),
1892 ];
1893
1894 let mut offset: usize = 0;
1895 for pl in mypls.iter() {
1896 let cl = CopperList::<MyMsgs>::new(1, *pl);
1897 offset +=
1898 encode_into_slice(&cl, &mut data.as_mut_slice()[offset..], standard()).unwrap();
1899 }
1900
1901 let reader = Cursor::new(data);
1902
1903 let mut iter = copperlists_reader::<MyMsgs>(reader);
1904 assert_eq!(iter.next().unwrap().msgs, MyMsgs((1, 2, 3.0)));
1905 assert_eq!(iter.next().unwrap().msgs, MyMsgs((2, 3, 4.0)));
1906 assert_eq!(iter.next().unwrap().msgs, MyMsgs((3, 4, 5.0)));
1907 assert_eq!(iter.next().unwrap().msgs, MyMsgs((4, 5, 6.0)));
1908 }
1909
1910 #[test]
1911 fn runtime_lifecycle_reader_retains_timestamps_and_events() {
1912 let records = vec![
1913 RuntimeLifecycleRecord {
1914 timestamp: CuTime::default(),
1915 event: RuntimeLifecycleEvent::Instantiated {
1916 config_source: RuntimeLifecycleConfigSource::BundledDefault,
1917 },
1918 },
1919 RuntimeLifecycleRecord {
1920 timestamp: CuTime::from_nanos(42),
1921 event: RuntimeLifecycleEvent::MissionStarted,
1922 },
1923 ];
1924 let mut bytes = Vec::new();
1925 for record in &records {
1926 bytes.extend(bincode::encode_to_vec(record, standard()).unwrap());
1927 }
1928
1929 assert_eq!(
1930 runtime_lifecycle_reader(Cursor::new(bytes)).collect::<Vec<_>>(),
1931 records
1932 );
1933 }
1934
1935 #[test]
1936 fn logstats_mission_defaults_to_log_then_named_default_then_first() {
1937 let config = CuConfig::deserialize_ron(
1938 r#"(
1939 missions: [(id: "zeta"), (id: "default"), (id: "alpha")],
1940 tasks: [],
1941 cnx: [],
1942 )"#,
1943 )
1944 .unwrap();
1945 assert_eq!(
1946 resolve_logstats_mission(&config, None, Some("zeta".to_string()))
1947 .unwrap()
1948 .as_deref(),
1949 Some("zeta")
1950 );
1951 assert!(resolve_logstats_mission(&config, None, Some("missing".to_string())).is_err());
1952 assert_eq!(
1953 resolve_logstats_mission(&config, Some("alpha".to_string()), None)
1954 .unwrap()
1955 .as_deref(),
1956 Some("alpha")
1957 );
1958
1959 let config = CuConfig::deserialize_ron(
1960 r#"(
1961 missions: [(id: "zeta"), (id: "alpha")],
1962 tasks: [],
1963 cnx: [],
1964 )"#,
1965 )
1966 .unwrap();
1967 assert_eq!(
1968 resolve_logstats_mission(&config, None, None)
1969 .unwrap()
1970 .as_deref(),
1971 Some("alpha")
1972 );
1973 }
1974
1975 #[test]
1976 fn schedule_optimizer_cli_is_explicit_and_measurements_are_validated() {
1977 let args = LogReaderCli::try_parse_from([
1978 "logreader",
1979 "robot.copper",
1980 "optimize-schedule",
1981 "--contract",
1982 "schedule.ron",
1983 "--output",
1984 "target/pgs",
1985 "--candidates",
1986 "2",
1987 "--measure",
1988 "plan-1=logs/plan-1.copper",
1989 ])
1990 .unwrap();
1991 let Command::OptimizeSchedule {
1992 contract,
1993 output,
1994 candidates,
1995 measurements,
1996 } = args.command
1997 else {
1998 panic!("expected optimize-schedule command");
1999 };
2000 assert_eq!(contract, PathBuf::from("schedule.ron"));
2001 assert_eq!(output, PathBuf::from("target/pgs"));
2002 assert_eq!(candidates, 2);
2003 assert_eq!(measurements, ["plan-1=logs/plan-1.copper"]);
2004 assert_eq!(
2005 parse_measurement(&measurements[0]).unwrap(),
2006 ("plan-1", Path::new("logs/plan-1.copper"))
2007 );
2008 assert!(parse_measurement("plan-1").is_err());
2009 assert!(parse_measurement("baseline=other.copper").is_err());
2010 assert_eq!(candidate_index("plan-2/round-3"), Some(1));
2011 assert_eq!(candidate_index("baseline"), None);
2012
2013 let config = CuConfig::deserialize_ron(
2014 r#"(
2015 missions: [(id: "drive"), (id: "park")],
2016 tasks: [],
2017 cnx: [],
2018 )"#,
2019 )
2020 .unwrap();
2021 assert!(resolve_schedule_mission(&config, &["drive".into(), "park".into()]).is_err());
2022 assert_eq!(
2023 resolve_schedule_mission(&config, &["park".into()])
2024 .unwrap()
2025 .as_deref(),
2026 Some("park")
2027 );
2028 }
2029}