Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,21 @@ and this project adheres to semantic versioning once public releases begin.

### Added

- Machine-readable run progress for long commands (Ticket 106). `sample`,
`propagate`, and `batch` can now stream structured JSONL progress so a
non-interactive worker can show live progress instead of a flat "running"
until exit. Two opt-in flags select it: `--progress-format jsonl` writes one
compact JSON record per interval to stderr, and `--progress-file PATH` writes
the same stream to a sidecar file. Each record is
`{"event":"progress","command":...,"completed":...,"total":...,"elapsed_s":...}`
with monotonically increasing `completed` and a guaranteed final record where
`completed == total` (sample count for `sample`/`propagate`, run count for
`batch`); `elapsed_s` is monotonic wall-clock. Progress is a stderr/sidecar
side-channel only — it never appears in the `--output` stream, adds no schema
or envelope version, and leaves the result envelope, deterministic results,
and exit codes unchanged. Off by default: a run with no progress flag behaves
byte-for-byte as before. A progress callback is threaded through
`run_monte_carlo`, `run_stochastic_propagation`, and `run_batch_manifest`.
- Contract-version discovery command (Ticket 105). A new read-only `bvlos-sim
schema-versions` command (alias `contracts`) prints the resolved `tool_version`
plus every supported output-envelope and input-schema version as canonical JSON
Expand Down
18 changes: 15 additions & 3 deletions adapters/batch_support.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Batch estimate execution support for CLI and tests."""

from collections.abc import Callable
from dataclasses import dataclass
from io import StringIO

Expand Down Expand Up @@ -117,10 +118,15 @@ def _run_estimate(run: BatchRun) -> BatchRunResult:
)


def run_batch_manifest(manifest: BatchManifest) -> list[BatchRunResult]:
def run_batch_manifest(
manifest: BatchManifest,
*,
progress: Callable[[int, int], None] | None = None,
) -> list[BatchRunResult]:
"""Run all estimates in a validated batch manifest."""
results: list[BatchRunResult] = []
for run in manifest.runs:
total = len(manifest.runs)
for index, run in enumerate(manifest.runs):
try:
results.append(_run_estimate(run))
except _BATCH_RUN_INPUT_ERRORS as exc:
Expand All @@ -134,6 +140,8 @@ def run_batch_manifest(manifest: BatchManifest) -> list[BatchRunResult]:
error_message=str(exc),
)
)
if progress is not None:
progress(index + 1, total)
return results


Expand Down Expand Up @@ -170,7 +178,11 @@ def render_batch_csv(results: list[BatchRunResult]) -> str:
"""Render the batch result table as CSV (suitable for import into spreadsheets)."""
rows = ["id,status,reserve_margin_percent,flight_time_s,warning_count"]
for r in results:
reserve = "" if r.reserve_margin_percent is None else f"{r.reserve_margin_percent:.2f}"
reserve = (
""
if r.reserve_margin_percent is None
else f"{r.reserve_margin_percent:.2f}"
)
flight_time = "" if r.flight_time_s is None else f"{r.flight_time_s:.1f}"
rows.append(f"{r.id},{r.status},{reserve},{flight_time},{r.warning_count}")
return "\n".join(rows) + "\n"
Expand Down
6 changes: 6 additions & 0 deletions adapters/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ class SoraOutputFormat(StrEnum):
MARKDOWN = "markdown"


class ProgressFormat(StrEnum):
NONE = "none"
JSONL = "jsonl"


_DOCUMENT_OUTPUT_FORMATS: dict[DocumentOutputFormat, OutputFormat] = {
DocumentOutputFormat.JSON: OutputFormat.JSON,
DocumentOutputFormat.MARKDOWN: OutputFormat.MARKDOWN,
Expand All @@ -74,6 +79,7 @@ class SoraOutputFormat(StrEnum):
"BatchOutputFormat",
"CliExitCode",
"DocumentOutputFormat",
"ProgressFormat",
"ScenarioExitCode",
"SoraOutputFormat",
"SummaryOutputFormat",
Expand Down
34 changes: 30 additions & 4 deletions adapters/commands/batch.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,15 @@
render_batch_table,
run_batch_manifest,
)
from adapters.cli_batch_support import BatchOutputFormat, _batch_exit_code, write_batch_outputs
from adapters.cli_batch_support import (
BatchOutputFormat,
_batch_exit_code,
write_batch_outputs,
)
from adapters.cli_support import OutputWriteError, _write_output
from adapters.envelope import OutputFormat
from adapters.io import InputLoadError, load_mission, load_vehicle
from adapters.progress import progress_reporter


BatchStdoutRenderer = Callable[[list[BatchRunResult]], str]
Expand All @@ -25,7 +30,9 @@
BatchOutputFormat.CSV: render_batch_csv,
}
_BATCH_FILE_OUTPUT_FORMATS = frozenset(
output_format for output_format in BatchOutputFormat if output_format != BatchOutputFormat.CSV
output_format
for output_format in BatchOutputFormat
if output_format != BatchOutputFormat.CSV
)


Expand Down Expand Up @@ -71,12 +78,26 @@ def _validate_batch_manifest(manifest: Path) -> None:

def batch(
manifest: Path = typer.Argument(..., exists=True, readable=True, resolve_path=True),
output_dir: Path | None = typer.Option(None, "--output-dir", help="Directory for per-run output files. Required when --format is not csv or summary."),
output_dir: Path | None = typer.Option(
None,
"--output-dir",
help="Directory for per-run output files. Required when --format is not csv or summary.",
),
format: BatchOutputFormat = typer.Option(
BatchOutputFormat.SUMMARY,
"--format",
help="Stdout format. Use csv for spreadsheet import. Use --output-dir with json/markdown/geojson/kml/checklist/profile to write per-run files.",
),
progress_format: cli.ProgressFormat = typer.Option(
cli.ProgressFormat.NONE,
"--progress-format",
help="Emit machine-readable progress. Use jsonl for one JSON record per run on stderr.",
),
progress_file: Path | None = typer.Option(
None,
"--progress-file",
help="Write JSONL progress to this file instead of stderr (implies --progress-format jsonl).",
),
validate_only: bool = typer.Option(
False,
"--validate-only",
Expand All @@ -100,7 +121,12 @@ def batch(
err=True,
)
batch_manifest = load_batch_manifest(manifest)
results = run_batch_manifest(batch_manifest)
with progress_reporter(
"batch",
enabled=progress_format is cli.ProgressFormat.JSONL,
progress_file=progress_file,
) as reporter:
results = run_batch_manifest(batch_manifest, progress=reporter)
_emit_batch_warnings(results)
_write_batch_file_outputs(
output_dir=output_dir,
Expand Down
48 changes: 35 additions & 13 deletions adapters/commands/propagate.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,37 @@
_write_output,
)
from adapters.io import InputLoadError, load_mission, load_vehicle
from adapters.progress import progress_reporter
from adapters.stochastic_envelope import build_stochastic_envelope
from adapters.stochastic_io import load_stochastic_plan, resolve_stochastic_asset_path


def propagate(
stochastic_file: Path = typer.Argument(
..., exists=True, readable=True, resolve_path=True,
...,
exists=True,
readable=True,
resolve_path=True,
help="Path to stochastic.v1 YAML file.",
),
format: cli.SummaryOutputFormat = typer.Option(
cli.SummaryOutputFormat.JSON,
"--format",
help="Output format. Use summary for a one-line feasibility and reserve result.",
),
output: Path | None = typer.Option(None, "--output", "-o", help="Write output to file instead of stdout."),
output: Path | None = typer.Option(
None, "--output", "-o", help="Write output to file instead of stdout."
),
progress_format: cli.ProgressFormat = typer.Option(
cli.ProgressFormat.NONE,
"--progress-format",
help="Emit machine-readable progress. Use jsonl for one JSON record per interval on stderr.",
),
progress_file: Path | None = typer.Option(
None,
"--progress-file",
help="Write JSONL progress to this file instead of stderr (implies --progress-format jsonl).",
),
validate_only: bool = typer.Option(
False,
"--validate-only",
Expand Down Expand Up @@ -69,17 +85,23 @@ def propagate(
mission_document=mission_document,
)

result = cli.run_stochastic_propagation(
plan,
mission_model,
vehicle_model,
wind_provider=mission_assets.wind_provider,
terrain_provider=mission_assets.terrain_provider,
population_provider=mission_assets.population_provider,
obstacle_provider=mission_assets.obstacle_provider,
geofences=mission_assets.geofences,
landing_zones=mission_assets.landing_zones,
)
with progress_reporter(
"propagate",
enabled=progress_format is cli.ProgressFormat.JSONL,
progress_file=progress_file,
) as reporter:
result = cli.run_stochastic_propagation(
plan,
mission_model,
vehicle_model,
wind_provider=mission_assets.wind_provider,
terrain_provider=mission_assets.terrain_provider,
population_provider=mission_assets.population_provider,
obstacle_provider=mission_assets.obstacle_provider,
geofences=mission_assets.geofences,
landing_zones=mission_assets.landing_zones,
progress=reporter,
)
envelope = build_stochastic_envelope(
result=result,
stochastic_document=stochastic_document,
Expand Down
53 changes: 39 additions & 14 deletions adapters/commands/sample.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,40 @@
_write_output,
)
from adapters.io import InputLoadError, load_mission, load_vehicle
from adapters.progress import progress_reporter
from adapters.uncertainty_envelope import build_uncertainty_envelope
from adapters.uncertainty_io import load_uncertainty_plan, resolve_uncertainty_asset_path
from adapters.uncertainty_io import (
load_uncertainty_plan,
resolve_uncertainty_asset_path,
)


def sample(
uncertainty_file: Path = typer.Argument(
..., exists=True, readable=True, resolve_path=True,
...,
exists=True,
readable=True,
resolve_path=True,
help="Path to uncertainty.v1 YAML file.",
),
format: cli.SummaryOutputFormat = typer.Option(
cli.SummaryOutputFormat.JSON,
"--format",
help="Output format. Use summary for a one-line feasibility and reserve result.",
),
output: Path | None = typer.Option(None, "--output", "-o", help="Write output to file instead of stdout."),
output: Path | None = typer.Option(
None, "--output", "-o", help="Write output to file instead of stdout."
),
progress_format: cli.ProgressFormat = typer.Option(
cli.ProgressFormat.NONE,
"--progress-format",
help="Emit machine-readable progress. Use jsonl for one JSON record per interval on stderr.",
),
progress_file: Path | None = typer.Option(
None,
"--progress-file",
help="Write JSONL progress to this file instead of stderr (implies --progress-format jsonl).",
),
validate_only: bool = typer.Option(
False,
"--validate-only",
Expand Down Expand Up @@ -69,17 +88,23 @@ def sample(
mission_document=mission_document,
)

result = cli.run_monte_carlo(
plan,
mission_model,
vehicle_model,
wind_provider=mission_assets.wind_provider,
terrain_provider=mission_assets.terrain_provider,
population_provider=mission_assets.population_provider,
obstacle_provider=mission_assets.obstacle_provider,
geofences=mission_assets.geofences,
landing_zones=mission_assets.landing_zones,
)
with progress_reporter(
"sample",
enabled=progress_format is cli.ProgressFormat.JSONL,
progress_file=progress_file,
) as reporter:
result = cli.run_monte_carlo(
plan,
mission_model,
vehicle_model,
wind_provider=mission_assets.wind_provider,
terrain_provider=mission_assets.terrain_provider,
population_provider=mission_assets.population_provider,
obstacle_provider=mission_assets.obstacle_provider,
geofences=mission_assets.geofences,
landing_zones=mission_assets.landing_zones,
progress=reporter,
)
envelope = build_uncertainty_envelope(
result=result,
uncertainty_document=uncertainty_document,
Expand Down
Loading
Loading