RoyAalekh's picture
refactored project structure. renamed scheduler dir to src
6a28f91
"""Unified CLI for Court Scheduling System.
This module provides a single entry point for key court scheduling operations:
- EDA pipeline execution
- Case generation
- Simulation runs
- Full workflow orchestration
"""
from __future__ import annotations
import sys
from pathlib import Path
import typer
from rich.console import Console
from rich.progress import Progress, SpinnerColumn, TextColumn
from cli import __version__
try:
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
except Exception:
pass
# Initialize Typer app and console
app = typer.Typer(
name="court-scheduler",
help="Court Scheduling System for Karnataka High Court",
add_completion=False,
)
# Use force_terminal=False to avoid legacy Windows rendering issues with Unicode
console = Console(legacy_windows=False)
@app.command()
def eda(
skip_clean: bool = typer.Option(
False, "--skip-clean", help="Skip data loading and cleaning"
),
skip_viz: bool = typer.Option(
False, "--skip-viz", help="Skip visualization generation"
),
skip_params: bool = typer.Option(
False, "--skip-params", help="Skip parameter extraction"
),
) -> None:
"""Run the EDA pipeline (load, explore, extract parameters)."""
console.print("[bold blue]Running EDA Pipeline[/bold blue]")
try:
# Import here to avoid loading heavy dependencies if not needed
from eda.exploration import run_exploration
from eda.load_clean import run_load_and_clean
from eda.parameters import run_parameter_export
with Progress(
TextColumn("[progress.description]{task.description}"),
console=console,
) as progress:
if not skip_clean:
task = progress.add_task("Step 1/3: Load and clean data...", total=None)
run_load_and_clean()
progress.update(task, completed=True)
console.print("Data loaded and cleaned")
if not skip_viz:
task = progress.add_task(
"Step 2/3: Generate visualizations...", total=None
)
run_exploration()
progress.update(task, completed=True)
console.print("Visualizations generated")
if not skip_params:
task = progress.add_task("Step 3/3: Extract parameters...", total=None)
run_parameter_export()
progress.update(task, completed=True)
console.print("Parameters extracted")
console.print("\n[bold]EDA Pipeline Complete[/bold]")
console.print("Outputs: reports/figures/")
except Exception as e:
console.print(f"[bold red]Error:[/bold red] {e}")
raise typer.Exit(code=1)
@app.command()
def generate(
config: Path = typer.Option( # noqa: B008
None,
"--config",
exists=True,
dir_okay=False,
readable=True,
help="Path to config (.toml or .json)",
),
interactive: bool = typer.Option(
False, "--interactive", help="Prompt for parameters interactively"
),
n_cases: int = typer.Option(
10000, "--cases", "-n", help="Number of cases to generate"
),
start_date: str = typer.Option(
"2022-01-01", "--start", help="Start date (YYYY-MM-DD)"
),
end_date: str = typer.Option("2023-12-31", "--end", help="End date (YYYY-MM-DD)"),
output: str = typer.Option(
"data/generated/cases.csv", "--output", "-o", help="Output CSV file"
),
seed: int = typer.Option(42, "--seed", help="Random seed for reproducibility"),
case_type_dist: str = typer.Option(
None,
"--case-type-dist",
help=(
'Custom case type distribution. Accepts JSON (e.g., \'{"Writ":0.6,"Civil":0.4}\') '
"or comma-separated pairs 'Writ:0.6,Civil:0.4'. Defaults to historical distribution."
),
),
) -> None:
"""Generate synthetic test cases for simulation."""
console.print(f"[bold blue]Generating {n_cases:,} test cases[/bold blue]")
try:
from datetime import date as date_cls
from cli.config import GenerateConfig, load_generate_config
from src.data.case_generator import CaseGenerator
# Resolve parameters: config -> interactive -> flags
if config:
cfg = load_generate_config(config)
else:
if interactive:
n_cases = typer.prompt("Number of cases", default=n_cases)
start_date = typer.prompt("Start date (YYYY-MM-DD)", default=start_date)
end_date = typer.prompt("End date (YYYY-MM-DD)", default=end_date)
output = typer.prompt("Output CSV path", default=output)
seed = typer.prompt("Random seed", default=seed)
cfg = GenerateConfig(
n_cases=n_cases,
start=date_cls.fromisoformat(start_date),
end=date_cls.fromisoformat(end_date),
output=Path(output),
seed=seed,
)
start = cfg.start
end = cfg.end
output_path = cfg.output
output_path.parent.mkdir(parents=True, exist_ok=True)
with Progress(
TextColumn("[progress.description]{task.description}"),
console=console,
) as progress:
task = progress.add_task("Generating cases...", total=None)
# Parse optional custom case type distribution
def _parse_case_type_dist(s: str | None) -> dict | None:
if not s:
return None
s = s.strip()
try:
import json
obj = json.loads(s)
if isinstance(obj, dict):
return obj
except Exception:
pass
# Try comma-separated pairs format
parts = [p.strip() for p in s.split(",") if p.strip()]
dist: dict[str, float] = {}
for part in parts:
if ":" not in part:
continue
k, v = part.split(":", 1)
k = k.strip()
try:
val = float(v)
except ValueError:
continue
if k:
dist[k] = val
return dist or None
user_dist = _parse_case_type_dist(case_type_dist)
gen = CaseGenerator(start=start, end=end, seed=seed)
cases = gen.generate(
n_cases, stage_mix_auto=True, case_type_distribution=user_dist
)
# Write primary cases file
CaseGenerator.to_csv(cases, output_path)
# Also write detailed hearings history alongside, for the dashboard/classifier
hearings_path = output_path.parent / "hearings.csv"
CaseGenerator.to_hearings_csv(cases, hearings_path)
progress.update(task, completed=True)
console.print(f"Generated {len(cases):,} cases")
console.print(f"Saved to: {output_path}")
console.print(f"Hearings history: {hearings_path}")
except Exception as e:
console.print(f"[bold red]Error:[/bold red] {e}")
raise typer.Exit(code=1)
@app.command()
def simulate(
config: Path = typer.Option(
None,
"--config",
exists=True,
dir_okay=False,
readable=True,
help="Path to config (.toml or .json)",
),
interactive: bool = typer.Option(
False, "--interactive", help="Prompt for parameters interactively"
),
cases_csv: str = typer.Option(
"data/generated/cases.csv", "--cases", help="Input cases CSV"
),
days: int = typer.Option(
384, "--days", "-d", help="Number of working days to simulate"
),
start_date: str = typer.Option(
None, "--start", help="Simulation start date (YYYY-MM-DD)"
),
policy: str = typer.Option(
"readiness", "--policy", "-p", help="Scheduling policy (fifo/age/readiness)"
),
seed: int = typer.Option(42, "--seed", help="Random seed"),
log_dir: str = typer.Option(
None, "--log-dir", "-o", help="Output directory for logs"
),
) -> None:
"""Run court scheduling simulation."""
console.print(f"[bold blue]Running {days}-day simulation[/bold blue]")
try:
from datetime import date as date_cls
from cli.config import SimulateConfig, load_simulate_config
from src.core.case import CaseStatus
from src.data.case_generator import CaseGenerator
from src.metrics.basic import gini
from src.simulation.engine import CourtSim, CourtSimConfig
# Resolve parameters: config -> interactive -> flags
if config:
scfg = load_simulate_config(config)
# CLI flags override config if provided
scfg = scfg.model_copy(
update={
"cases": Path(cases_csv) if cases_csv else scfg.cases,
"days": days if days else scfg.days,
"start": (
date_cls.fromisoformat(start_date) if start_date else scfg.start
),
"policy": policy if policy else scfg.policy,
"seed": seed if seed else scfg.seed,
"log_dir": (Path(log_dir) if log_dir else scfg.log_dir),
}
)
else:
if interactive:
cases_csv = typer.prompt("Cases CSV", default=cases_csv)
days = typer.prompt("Days to simulate", default=days)
start_date = (
typer.prompt(
"Start date (YYYY-MM-DD) or blank", default=start_date or ""
)
or None
)
policy = typer.prompt("Policy [readiness|fifo|age]", default=policy)
seed = typer.prompt("Random seed", default=seed)
log_dir = (
typer.prompt("Log dir (or blank)", default=log_dir or "") or None
)
scfg = SimulateConfig(
cases=Path(cases_csv),
days=days,
start=(date_cls.fromisoformat(start_date) if start_date else None),
policy=policy,
seed=seed,
log_dir=(Path(log_dir) if log_dir else None),
)
# Load cases
path = scfg.cases
if path.exists():
cases = CaseGenerator.from_csv(path)
start = scfg.start or (
max(c.filed_date for c in cases) if cases else date_cls.today()
)
else:
console.print(
f"[yellow]Warning:[/yellow] {path} not found. Generating test cases..."
)
start = scfg.start or date_cls.today().replace(day=1)
gen = CaseGenerator(start=start, end=start.replace(day=28), seed=scfg.seed)
cases = gen.generate(n_cases=5 * 151)
# Run simulation
cfg = CourtSimConfig(
start=start,
days=scfg.days,
seed=scfg.seed,
policy=scfg.policy,
duration_percentile="median",
log_dir=scfg.log_dir,
)
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
console=console,
) as progress:
task = progress.add_task(f"Simulating {days} days...", total=None)
sim = CourtSim(cfg, cases)
res = sim.run()
progress.update(task, completed=True)
# Display results
console.print("\n[bold green]Simulation Complete![/bold green]")
console.print(f"\nHorizon: {cfg.start} -> {res.end_date} ({days} days)")
console.print("\n[bold]Hearing Metrics:[/bold]")
console.print(f" Total: {res.hearings_total:,}")
console.print(
f" Heard: {res.hearings_heard:,} ({res.hearings_heard / max(1, res.hearings_total):.1%})"
)
console.print(
f" Adjourned: {res.hearings_adjourned:,} ({res.hearings_adjourned / max(1, res.hearings_total):.1%})"
)
disp_times = [
(c.disposal_date - c.filed_date).days
for c in cases
if c.disposal_date is not None and c.status == CaseStatus.DISPOSED
]
gini_disp = gini(disp_times) if disp_times else 0.0
console.print("\n[bold]Disposal Metrics:[/bold]")
console.print(
f" Cases disposed: {res.disposals:,} ({res.disposals / len(cases):.1%})"
)
console.print(f" Gini coefficient: {gini_disp:.3f}")
console.print("\n[bold]Efficiency:[/bold]")
console.print(f" Utilization: {res.utilization:.1%}")
console.print(f" Avg hearings/day: {res.hearings_total / days:.1f}")
if log_dir:
console.print("\n[bold cyan]Output Files:[/bold cyan]")
console.print(f" - {log_dir}/report.txt")
console.print(f" - {log_dir}/metrics.csv")
console.print(f" - {log_dir}/events.csv")
except Exception as e:
console.print(f"[bold red]Error:[/bold red] {e}")
raise typer.Exit(code=1)
@app.command()
def workflow(
n_cases: int = typer.Option(
10000, "--cases", "-n", help="Number of cases to generate"
),
sim_days: int = typer.Option(384, "--days", "-d", help="Simulation days"),
output_dir: str = typer.Option(
"data/workflow_run", "--output", "-o", help="Output directory"
),
seed: int = typer.Option(42, "--seed", help="Random seed"),
) -> None:
"""Run full workflow: EDA -> Generate -> Simulate -> Report."""
console.print("[bold blue]Running Full Workflow[/bold blue]\n")
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
try:
# Step 1: EDA (skip if already done recently)
console.print("[bold]Step 1/3:[/bold] EDA Pipeline")
console.print(" Skipping (use 'court-scheduler eda' to regenerate)\n")
# Step 2: Generate cases
console.print("[bold]Step 2/3:[/bold] Generate Cases")
cases_file = output_path / "cases.csv"
from datetime import date as date_cls
from src.data.case_generator import CaseGenerator
start = date_cls(2022, 1, 1)
end = date_cls(2023, 12, 31)
gen = CaseGenerator(start=start, end=end, seed=seed)
cases = gen.generate(n_cases, stage_mix_auto=True)
CaseGenerator.to_csv(cases, cases_file)
console.print(f" Generated {len(cases):,} cases\n")
# Step 3: Run simulation
console.print("[bold]Step 3/3:[/bold] Run Simulation")
from src.simulation.engine import CourtSim, CourtSimConfig
sim_start = max(c.filed_date for c in cases)
cfg = CourtSimConfig(
start=sim_start,
days=sim_days,
seed=seed,
policy="readiness",
log_dir=output_path,
)
sim = CourtSim(cfg, cases)
sim.run()
console.print(" Simulation complete\n")
# Summary
console.print("[bold]Workflow Complete[/bold]")
console.print(f"\nResults: {output_path}/")
console.print(f" - cases.csv ({len(cases):,} cases)")
console.print(" - report.txt (simulation summary)")
console.print(" - metrics.csv (daily metrics)")
console.print(" - events.csv (event log)")
except Exception as e:
console.print(f"[bold red]Error:[/bold red] {e}")
raise typer.Exit(code=1)
@app.command()
def dashboard(
port: int = typer.Option(8501, "--port", "-p", help="Port to run dashboard on"),
host: str = typer.Option("localhost", "--host", help="Host address to bind to"),
) -> None:
"""Launch interactive dashboard."""
console.print("[bold blue]Launching Interactive Dashboard[/bold blue]")
console.print(f"Dashboard will be available at: http://{host}:{port}")
console.print("Press Ctrl+C to stop the dashboard\n")
try:
import subprocess
import sys
# Get path to dashboard app
app_path = Path(__file__).parent.parent / "scheduler" / "dashboard" / "app.py"
if not app_path.exists():
console.print(
f"[bold red]Error:[/bold red] Dashboard app not found at {app_path}"
)
raise typer.Exit(code=1)
# Run streamlit
cmd = [
sys.executable,
"-m",
"streamlit",
"run",
str(app_path),
"--server.port",
str(port),
"--server.address",
host,
"--browser.gatherUsageStats",
"false",
]
subprocess.run(cmd)
except KeyboardInterrupt:
console.print("\n[yellow]Dashboard stopped[/yellow]")
except Exception as e:
console.print(f"[bold red]Error:[/bold red] {e}")
raise typer.Exit(code=1)
@app.command()
def version() -> None:
"""Show version information."""
console.print(f"Court Scheduler CLI v{__version__}")
console.print("Court Scheduling System for Karnataka High Court")
def main() -> None:
"""Entry point for CLI."""
app()
if __name__ == "__main__":
main()