diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 4a4dbd4..0000000 --- a/.dockerignore +++ /dev/null @@ -1,39 +0,0 @@ -# Keep the build context small and reproducible: send only what the image -# needs (pyproject, the two packages, and the proto-gen script). Everything -# below is either regenerated inside the image, host-only, or irrelevant. - -# Version control / CI -.git/ -.github/ - -# The desktop dashboard and tests are not part of the server image. -gui/ -tests/ - -# Python caches and build artifacts -__pycache__/ -*.py[cod] -*.egg-info/ -build/ -dist/ -.pytest_cache/ -.mypy_cache/ -.ruff_cache/ - -# Generated gRPC stubs — regenerated by scripts/gen_proto.sh inside the build. -service/generated/ - -# Local virtualenvs and environments -venv/ -.venv/ -.env - -# Editor / OS noise and local notes -.DS_Store -*.ipynb_checkpoints/ -results.csv - -# Docker files themselves don't need to be in the context -Dockerfile -.dockerignore -docker-compose.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1edabd..7461656 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,65 +20,11 @@ jobs: python -m pip install --upgrade pip pip install -e ".[dev]" - - name: Generate gRPC stubs - run: bash scripts/gen_proto.sh - - name: Ruff (lint) run: ruff check . - name: Mypy (type-check) - run: mypy contrail_env service + run: mypy contrail_env - name: Pytest run: pytest -q - - docker-build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Build solver image - run: docker build -t contrail-solver:ci . - - - name: Smoke-test the server boots and accepts connections - run: | - docker run -d --name solver contrail-solver:ci - ok=0 - for _ in $(seq 1 30); do - if docker exec solver python -c \ - "import socket; socket.create_connection(('localhost', 50051), 2).close()"; then - echo "solver is up and accepting connections"; ok=1; break - fi - sleep 1 - done - docker logs solver - docker rm -f solver - test "$ok" = 1 - - docker-publish: - runs-on: ubuntu-latest - # Only publish on pushes to main — not on pull requests. - # The full suite AND the image smoke-test must pass first. - needs: [lint-type-test, docker-build] - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - permissions: - contents: read - packages: write # needed to push to ghcr.io - steps: - - uses: actions/checkout@v4 - - - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Build and push image to ghcr.io - uses: docker/build-push-action@v5 - with: - context: . - push: true - tags: | - ghcr.io/${{ github.repository_owner }}/contrail-solver:latest - ghcr.io/${{ github.repository_owner }}/contrail-solver:${{ github.sha }} diff --git a/.gitignore b/.gitignore index 2df127c..6b5e929 100644 --- a/.gitignore +++ b/.gitignore @@ -14,8 +14,5 @@ build/ .mypy_cache/ .ruff_cache/ -# Generated gRPC stubs (regenerated by scripts/gen_proto.sh) -service/generated/ - # Benchmark output results.csv diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index e9c8f30..0000000 --- a/Dockerfile +++ /dev/null @@ -1,79 +0,0 @@ -# syntax=docker/dockerfile:1 -# -# Containerized CP-SAT solver service (gRPC Solve RPC + ZMQ progress stream). -# -# The image ships ONLY the headless solver runtime — numpy / OR-Tools / gRPC / -# ZMQ — and NOT the PyQt6 desktop dashboard, so it stays small and needs no -# GUI/X11 system libraries. Build once, run anywhere the ports are reachable: -# -# docker build -t contrail-solver . -# docker run --rm -p 50051:50051 -p 5556:5556 contrail-solver -# -# --------------------------------------------------------------------------- -# Stage 1: builder — install dependencies into an isolated virtualenv and -# generate the gRPC stubs from the .proto. Kept separate so the runtime image -# carries only the finished venv + source, never pip's build caches. -# --------------------------------------------------------------------------- -FROM python:3.11-slim AS builder - -ENV PIP_NO_CACHE_DIR=1 \ - PIP_DISABLE_PIP_VERSION_CHECK=1 - -# All third-party deps ship manylinux wheels for CPython 3.11, so no compiler -# toolchain is needed — the slim base is enough. -RUN python -m venv /opt/venv -ENV PATH="/opt/venv/bin:$PATH" - -WORKDIR /app - -# Dependency layer first: these inputs change rarely, so Docker reuses the -# cached `pip install` whenever only application code changes. -COPY pyproject.toml README.md ./ -COPY contrail_env/ ./contrail_env/ -COPY service/ ./service/ -COPY scripts/ ./scripts/ -# Only contrail_env + service ship here — the image's one job is the CP-SAT gRPC -# server. The quantum solver modules (pasqal_analog, xanadu_gbs) ride along in -# contrail_env and run on their built-in fallbacks; the optional [quantum] SDKs -# (pulser/strawberryfields) are not installed, keeping the runtime image lean. -RUN pip install . - -# The gRPC stubs are gitignored — generate them from solver.proto at build time. -RUN bash scripts/gen_proto.sh - -# --------------------------------------------------------------------------- -# Stage 2: runtime — copy the ready-built venv + source, run as a non-root -# user, and expose the service ports. -# --------------------------------------------------------------------------- -FROM python:3.11-slim AS runtime - -# PYTHONPATH=/app makes the source tree (which carries the freshly generated -# service/generated stubs) take precedence over the installed copy. -# CONTRAIL_GRPC_HOST=0.0.0.0 makes the bound port reachable from outside the -# container; local runs still default to localhost (see service/server.py). -ENV PYTHONUNBUFFERED=1 \ - PYTHONDONTWRITEBYTECODE=1 \ - PATH="/opt/venv/bin:$PATH" \ - PYTHONPATH=/app \ - CONTRAIL_GRPC_HOST=0.0.0.0 \ - CONTRAIL_GRPC_PORT=50051 - -WORKDIR /app - -COPY --from=builder /opt/venv /opt/venv -COPY --from=builder /app /app - -# Least privilege: drop root and run as an unprivileged user. -RUN useradd --create-home --uid 1000 appuser && chown -R appuser:appuser /app -USER appuser - -# 50051 = gRPC Solve RPC, 5556 = ZMQ progress publisher. -EXPOSE 50051 5556 - -# Liveness probe: the gRPC port is accepting TCP connections. (A full gRPC -# health service would need grpc_health_probe; a socket connect is enough to -# tell the orchestrator the process is up and listening.) -HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD python -c "import socket; socket.create_connection(('localhost', 50051), 2).close()" || exit 1 - -CMD ["python", "-m", "service.server"] diff --git a/README.md b/README.md index 478c820..3ebb668 100644 --- a/README.md +++ b/README.md @@ -7,15 +7,16 @@ subject to one option per flight, pairwise contrail conflicts, and sector-capaci limits. The problem is built on a synthetic airspace, encoded as a QUBO, and solved three ways: -- **CP-SAT** (OR-Tools) — the classical ground-truth verifier, behind a gRPC service; +- **CP-SAT** (OR-Tools) — the classical ground-truth verifier; - **Pasqal analog-QAOA** — a hand-coded adiabatic Ω(t), δ(t) schedule on the Rydberg blockade Hamiltonian, tuned by Bayesian optimization (`contrail_env/pasqal_analog.py`); - **Xanadu GBS** — Gaussian Boson Sampling of the Takagi-decomposed, WAW-weighted complement graph (`contrail_env/xanadu_gbs.py`). -A PyQt6 dashboard drives the service, streams solver progress over ZMQ, and runs the -head-to-head benchmark (approximation ratio vs the CP-SAT optimum with bootstrap CIs, -raw feasibility rate, wall clock). +A PyQt6 dashboard builds scenarios, solves them **in-process** with a live +convergence curve, and runs the head-to-head benchmark (approximation ratio vs the +CP-SAT optimum with bootstrap CIs, raw feasibility rate, wall clock). It's a single +desktop process — no server, no broker. The quantum pipelines need no quantum SDKs: each ships a dependency-free, physically faithful backend (a split-operator state-vector simulator of the Rydberg Hamiltonian; @@ -23,70 +24,24 @@ an exact Metropolis–Hastings sampler of the GBS distribution P(S) ∝ |Haf(B_S Installing `pip install -e ".[quantum]"` switches them to Pulser's QuTiP emulator and Strawberry Fields' gaussian backend automatically. -## Install +## Install & run ``` -pip install -e . # core: the headless solver service -pip install -e ".[gui]" # add the PyQt6 desktop dashboard -bash scripts/gen_proto.sh # Windows: .\scripts\gen_proto.ps1 +pip install -e ".[gui]" # core + the PyQt6 desktop dashboard +python gui/app.py # launch the dashboard ``` -Requires Python 3.11+. Core deps are just the solver runtime (numpy, OR-Tools, -gRPC, ZMQ); the Qt/OpenGL dashboard stack lives in the `gui` extra so the -deployed server stays lean. The gRPC stubs are generated from -`service/proto/solver.proto`, not committed. +Requires Python 3.11+. Core deps are just the solver runtime (numpy, OR-Tools); the +Qt/OpenGL dashboard stack lives in the `gui` extra, and the optional `[quantum]` +SDKs in `[quantum]`. -## Run - -``` -python -m service.server # gRPC solver on localhost:50051 -python gui/app.py # dashboard, in a second terminal ([gui] extra) -``` - -## Docker - -The solver service is containerized (the headless gRPC server only — not the -desktop GUI). The bind host is read from the environment, so the container -binds `0.0.0.0` while local runs default to `localhost`. - -### Pull the pre-built image (easiest) - -A Docker image is published to the GitHub Container Registry on every push to -main. No cloning or building required — just Docker Desktop installed. - -``` -docker pull ghcr.io/jaewonyun1234/contrail-solver:latest -docker run --rm -p 50051:50051 -p 5556:5556 ghcr.io/jaewonyun1234/contrail-solver:latest -``` - -Then run the dashboard on your machine: - -``` -pip install -e ".[gui]" -python gui/app.py -``` - -The dashboard connects to the solver on `localhost:50051` automatically. - -### Build locally from source - -``` -docker compose up --build # build + run; gRPC on :50051, progress on :5556 -# or, without compose: -docker build -t contrail-solver . -docker run --rm -p 50051:50051 -p 5556:5556 contrail-solver -``` - -CI builds the image, smoke-tests that the server boots, and publishes it to -ghcr.io on every push to main. - -The dashboard has six tabs: live CP-SAT convergence (over ZMQ), the conflict-graph -topology, QUBO matrix statistics (size, sparsity, penalty constants), the -chosen-option trade-offs, the quantum benchmark (CP-SAT vs Pasqal vs Xanadu over +The dashboard has six tabs: live CP-SAT convergence (objective vs improvement), the +conflict-graph topology, QUBO matrix statistics (size, sparsity, penalty constants), +the chosen-option trade-offs, the quantum benchmark (CP-SAT vs Pasqal vs Xanadu over N seeds, with live convergence curves for the BO loop and the GBS sampler), and a -geographic map — the ISSR risk as a marker overlay on a real Plotly -`geo` basemap (country borders / coastlines, drawn with SVG and bundled offline -vectors, so it needs no WebGL or network) with the chosen vs context routes on top. +geographic map — the ISSR risk as a marker overlay on a real Plotly `geo` basemap +(country borders / coastlines, drawn with SVG and bundled offline vectors, so it +needs no WebGL or network) with the chosen vs context routes animated on top. The benchmark also runs headless: @@ -107,11 +62,11 @@ pluggable without touching `World` or the QUBO assembly. ``` contrail_env/ synthetic environment, ISSR field, geo anchor, QUBO assembly, CP-SAT solver, quantum pipelines (pasqal_analog, xanadu_gbs, - quantum_common, bayes_opt) and the benchmark protocol (benchmark.py) -service/ gRPC service, ZMQ progress streaming, client -gui/ PyQt6 dashboard + quantum_common, bayes_opt), the scenario builder (scenario.py), + and the benchmark protocol (benchmark.py) +gui/ PyQt6 dashboard (builds + solves scenarios in-process) tests/ environment build, CP-SAT vs brute-force, quantum solvers vs - brute-force, benchmark round-trip, gRPC round-trip, GUI map panel + brute-force, benchmark round-trip, GUI map panel ``` ## Development @@ -119,7 +74,7 @@ tests/ environment build, CP-SAT vs brute-force, quantum solvers vs ``` pip install -e ".[dev]" ruff check . -mypy contrail_env service +mypy contrail_env pytest ``` diff --git a/contrail_env/benchmark.py b/contrail_env/benchmark.py index 4fa1603..5cadc69 100644 --- a/contrail_env/benchmark.py +++ b/contrail_env/benchmark.py @@ -18,9 +18,9 @@ with bootstrap 95% confidence intervals on the approximation ratio. The scenario is supplied as a factory `seed -> (evals, conflicts, buckets)` -so this module stays decoupled from the gRPC layer: the GUI passes a -factory built from its ScenarioConfig; the CLI below builds one from -contrail_env defaults. +so this module stays decoupled from any caller: the GUI passes a factory +built from its ScenarioConfig; the CLI below builds one from contrail_env +defaults. """ from __future__ import annotations diff --git a/service/scenario.py b/contrail_env/scenario.py similarity index 58% rename from service/scenario.py rename to contrail_env/scenario.py index 420d99b..5c8681a 100644 --- a/service/scenario.py +++ b/contrail_env/scenario.py @@ -1,9 +1,10 @@ """ -scenario.py — Shared scenario construction (server + GUI use the same logic). +scenario.py — Build and solve a scenario in one process (no network layer). -A ScenarioConfig is fully seeded, so building from it is deterministic: the -server solves exactly the problem the GUI draws. Keeping this in one place -guarantees the two never drift apart. +A `ScenarioConfig` is fully seeded, so building from it is deterministic: the +same config always yields the same problem. This module owns the config object, +the scenario builders, and an in-process CP-SAT solve, so the desktop app runs +as a single process — no gRPC service, no ZMQ broker. Per-flight initial flight level =============================== @@ -16,6 +17,8 @@ from __future__ import annotations import math +from collections.abc import Callable +from dataclasses import dataclass, field import numpy as np @@ -33,16 +36,54 @@ default_european_world, fl_to_m, mach_to_ms, + solve_cpsat, ) -from .generated import solver_pb2 - -# Used when a client leaves all three cost weights at 0 (proto3 cannot tell -# "unset" from "0"): fall back to the env defaults instead of all-costs-zero. +# Used when the caller leaves all three cost weights at 0: fall back to sensible +# defaults instead of an all-costs-zero (degenerate) objective. _DEFAULT_WEIGHTS = (1.0, 5.0, 0.5) -def cost_weights(cfg: solver_pb2.ScenarioConfig) -> tuple[float, float, float]: +@dataclass +class ScenarioConfig: + """Everything needed to build + solve one deterministic scenario.""" + + seed: int = 42 + n_flights: int = 4 + n_issr_blobs: int = 6 + alpha_fuel: float = 0.0 + beta_contrail: float = 0.0 + gamma_disruption: float = 0.0 + corridor_frac: float = 0.0 + snapshot_window_s: float = 0.0 + time_limit_s: float = 10.0 + issr_threshold: float = 0.0 + + +@dataclass +class FlightChoice: + """The chosen option for one flight, with its cost breakdown.""" + + flight_name: str + chosen_option: int + fuel_kg: float + contrail_cells: int + disruption_flmin: float + + +@dataclass +class SolveResult: + """The outcome of a CP-SAT solve (what the dashboard displays).""" + + objective: float + status: str + wall_clock_s: float + n_conflicts: int + n_options_total: int + choices: list[FlightChoice] = field(default_factory=list) + + +def cost_weights(cfg: ScenarioConfig) -> tuple[float, float, float]: """(alpha_fuel, beta_contrail, gamma_disruption), with an all-zero fallback.""" weights = (cfg.alpha_fuel, cfg.beta_contrail, cfg.gamma_disruption) if weights == (0.0, 0.0, 0.0): @@ -50,7 +91,7 @@ def cost_weights(cfg: solver_pb2.ScenarioConfig) -> tuple[float, float, float]: return weights -def build_world_and_flights(cfg: solver_pb2.ScenarioConfig) -> tuple[World, list[Flight]]: +def build_world_and_flights(cfg: ScenarioConfig) -> tuple[World, list[Flight]]: """Build the world and flights for a config. Each flight gets (seed-deterministically): @@ -58,21 +99,12 @@ def build_world_and_flights(cfg: solver_pb2.ScenarioConfig) -> tuple[World, list come from any direction yet still overlap and conflict, and * its own baseline cruise flight level. """ - source = cfg.issr_source or "synthetic" - if source != "synthetic": - raise ValueError(f"unknown issr_source {source!r} (use 'synthetic')") world = default_european_world(seed=cfg.seed, n_issr_blobs=cfg.n_issr_blobs) # Threshold drives what counts as a contrail (cell RHi-excess > threshold), # so it affects the solve, not just the picture. 0 means "use the default". if cfg.issr_threshold > 0: world.issr.threshold = float(cfg.issr_threshold) - flight_source = cfg.flight_source or "synthetic" - if flight_source != "synthetic": - raise ValueError( - f"unknown flight_source {flight_source!r} (use 'synthetic')" - ) - flights = build_random_flights( n_flights=cfg.n_flights, world=world, @@ -122,7 +154,7 @@ def _clamp(v: float, lo: float, hi: float) -> float: def build_scenario_full( - cfg: solver_pb2.ScenarioConfig, + cfg: ScenarioConfig, ) -> tuple[World, list[Flight], list[EvaluatedOption], list[ConflictEdge], list[CapacityBucket]]: """Full scenario: world, flights, evaluated options, conflicts, capacity buckets.""" world, flights = build_world_and_flights(cfg) @@ -135,3 +167,40 @@ def build_scenario_full( conflicts = build_conflict_graph(evals, world) buckets = build_capacity_buckets(evals, world) return world, flights, evals, conflicts, buckets + + +def solve_scenario( + cfg: ScenarioConfig, + on_progress: Callable[[int, float], None] | None = None, +) -> SolveResult: + """Build the scenario for `cfg` and solve it with CP-SAT, in this process. + + `on_progress(improvement_index, objective)` is invoked once per improved + incumbent — wire it straight to a UI signal for a live convergence curve. + """ + _world, _flights, evals, conflicts, buckets = build_scenario_full(cfg) + result = solve_cpsat( + evals, + conflicts, + buckets, + time_limit_s=cfg.time_limit_s or 10.0, + on_progress=on_progress, + ) + choices = [ + FlightChoice( + flight_name=evals[i].flight_name, + chosen_option=evals[i].option_index, + fuel_kg=evals[i].fuel_kg, + contrail_cells=evals[i].contrail_cells, + disruption_flmin=evals[i].disruption_FLmin, + ) + for i in result.chosen_eval_indices + ] + return SolveResult( + objective=result.objective, + status=result.status, + wall_clock_s=result.wall_clock_s, + n_conflicts=len(conflicts), + n_options_total=len(evals), + choices=choices, + ) diff --git a/contrail_env/solver_cpsat.py b/contrail_env/solver_cpsat.py index 8461702..1f9f1b4 100644 --- a/contrail_env/solver_cpsat.py +++ b/contrail_env/solver_cpsat.py @@ -101,7 +101,7 @@ class _ProgressCallback(cp_model.CpSolverSolutionCallback): CP-SAT calls `on_solution_callback` every time it finds a new, strictly better feasible solution. We use that to (a) count improvements and (b) stream the convergence curve to whoever passed `on_progress` - (the gRPC server wires this to ZMQ). + (the dashboard wires this to its live convergence curve). """ def __init__(self, on_progress: Callable[[int, float], None] | None) -> None: diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 9653e47..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,20 +0,0 @@ -# Brings up the containerized CP-SAT solver service. -# -# docker compose up --build -# -# Then point a client (or the PyQt6 dashboard) at localhost:50051; live solver -# progress is published on tcp://localhost:5556. -services: - solver: - build: - context: . - dockerfile: Dockerfile - image: contrail-solver:latest - container_name: contrail-solver - ports: - - "50051:50051" # gRPC Solve RPC - - "5556:5556" # ZMQ progress stream - environment: - CONTRAIL_GRPC_HOST: "0.0.0.0" - CONTRAIL_GRPC_PORT: "50051" - restart: unless-stopped diff --git a/gui/app.py b/gui/app.py index b1b871b..d06bef2 100644 --- a/gui/app.py +++ b/gui/app.py @@ -9,19 +9,19 @@ DATA FLOW ========= -* The CP-SAT solve runs on the gRPC server; the GUI reaches it through - SolveWorker (a QThread) and streams progress over ZMQ — the UI never blocks. -* The server returns only the solution, so the dashboard rebuilds the QUBO and - conflict graph LOCALLY from the same (seeded) ScenarioConfig via - service.scenario.build_scenario_full + contrail_env.assemble_qubo. Because the - scenario is fully seeded, the reconstruction matches what the server solved. -* The quantum benchmark (tab 5) runs IN-PROCESS in a worker thread: the - quantum samplers live in contrail_env (pasqal_analog, xanadu_gbs) and the - protocol in contrail_env.benchmark — no service round-trip needed. +* Everything runs IN-PROCESS — no server, no broker. The CP-SAT solve runs off + the UI thread in SolveWorker (a QThread); its progress callback fires the Qt + `progress` signal so the convergence curve updates live without blocking. +* The dashboard builds the QUBO and conflict graph from the same (seeded) + ScenarioConfig via contrail_env.scenario.build_scenario_full + + contrail_env.assemble_qubo, so every panel shows exactly what was solved. +* The quantum benchmark (tab 5) likewise runs in a worker thread: the samplers + live in contrail_env (pasqal_analog, xanadu_gbs) and the protocol in + contrail_env.benchmark. PANELS ====== -1. Live CP-SAT convergence — objective vs improvement index (ZMQ stream). +1. Live CP-SAT convergence — objective vs improvement index (live, in-process). 2. Conflict-graph topology — option nodes grouped by flight, conflict edges. 3. QUBO & matrix analytics — size, sparsity, penalty constants, energy offset, and a heatmap of |Q|. @@ -44,10 +44,9 @@ import random import sys import tempfile -import threading import time from collections import OrderedDict -from dataclasses import dataclass +from dataclasses import dataclass, replace from pathlib import Path import numpy as np @@ -88,16 +87,11 @@ from contrail_env.benchmark import SOLVER_NAMES, run_benchmark from contrail_env.geo import EUROPEAN_ANCHOR from contrail_env.pasqal_analog import MAX_STATEVECTOR_QUBITS -from service.client import DEFAULT_SERVER_ADDRESS, SolverClient -from service.generated import solver_pb2 -from service.progress import DEFAULT_SUB_ADDRESS, subscribe -from service.scenario import build_scenario_full +from contrail_env.scenario import ScenarioConfig, build_scenario_full, solve_scenario # Fixed scenario knobs not exposed as controls (sensible defaults per the brief). CORRIDOR_FRAC = 0.05 SNAPSHOT_WINDOW_S = 300.0 -# Head start (seconds) for the SUB socket to connect before the solve emits. -_SUB_HEAD_START_S = 0.25 # Altitude (FL) of the horizontal slice the map heatmap shows. A 2-D map can # only show one altitude of the 3-D risk field; FL360 is the middle of the band. _MAP_SLICE_FL = 360 @@ -404,53 +398,34 @@ def build_map_figure( # ============================================================================= class SolveWorker(QThread): - """Runs one solve off the UI thread, streaming progress via signals.""" + """Runs one CP-SAT solve off the UI thread, streaming progress via signals. + + The solve runs in-process (contrail_env.scenario.solve_scenario); the + progress callback fires on this worker thread and the Qt signal hands each + incumbent to the UI thread, so the dashboard stays responsive with no + network service involved. + """ progress = pyqtSignal(int, float) - finished_ok = pyqtSignal(object) # solver_pb2.SolveResponse + finished_ok = pyqtSignal(object) # contrail_env.scenario.SolveResult failed = pyqtSignal(str) - def __init__( - self, - cfg: solver_pb2.ScenarioConfig, - server_address: str = DEFAULT_SERVER_ADDRESS, - sub_address: str = DEFAULT_SUB_ADDRESS, - ) -> None: + def __init__(self, cfg: ScenarioConfig) -> None: super().__init__() self._cfg = cfg - self._server_address = server_address - self._sub_address = sub_address def run(self) -> None: - box: dict[str, object] = {} - - def do_solve() -> None: - # Brief delay so the subscriber below connects first (slow joiner). - time.sleep(_SUB_HEAD_START_S) - try: - with SolverClient(self._server_address) as client: - box["resp"] = client.solve(self._cfg) - except Exception as exc: - box["err"] = exc - - solve_thread = threading.Thread(target=do_solve, daemon=True) - solve_thread.start() - try: - for improvement, objective in subscribe( - self._cfg.progress_topic, - self._sub_address, - stop=lambda: not solve_thread.is_alive(), - ): - self.progress.emit(improvement, objective) + result = solve_scenario( + self._cfg, + on_progress=lambda improvement, objective: self.progress.emit( + improvement, objective + ), + ) except Exception as exc: - box.setdefault("err", exc) - - solve_thread.join() - if "err" in box: - self.failed.emit(str(box["err"])) + self.failed.emit(str(exc)) else: - self.finished_ok.emit(box["resp"]) + self.finished_ok.emit(result) # ============================================================================= @@ -461,7 +436,7 @@ class BenchmarkWorker(QThread): """One full benchmark sweep: CP-SAT vs Pasqal vs Xanadu over N seeds. The scenario factory reuses the SAME seeded construction as the rest of - the dashboard (service.scenario.build_scenario_full), so the instances + the dashboard (contrail_env.scenario.build_scenario_full), so the instances benchmarked here are exactly the ones the other tabs display. """ @@ -473,7 +448,7 @@ class BenchmarkWorker(QThread): def __init__( self, - cfg: solver_pb2.ScenarioConfig, + cfg: ScenarioConfig, seeds: list[int], n_shots: int, bo_iters: int, @@ -486,9 +461,7 @@ def __init__( def run(self) -> None: def factory(seed: int): - cfg = solver_pb2.ScenarioConfig() - cfg.CopyFrom(self._cfg) - cfg.seed = seed + cfg = replace(self._cfg, seed=seed) _world, _flights, evals, conflicts, buckets = build_scenario_full(cfg) return evals, conflicts, buckets @@ -519,7 +492,7 @@ def __init__(self) -> None: self._worker: SolveWorker | None = None self._bench_worker: BenchmarkWorker | None = None - self._pending_cfg: solver_pb2.ScenarioConfig | None = None + self._pending_cfg: ScenarioConfig | None = None # Benchmark progress state (see _on_bench_phase / _refresh_bench_status). self._bench_t0 = 0.0 @@ -621,7 +594,7 @@ def _build_ui(self) -> None: self.setCentralWidget(central) def _build_convergence_tab(self) -> QWidget: - self.conv_plot = pg.PlotWidget(title="CP-SAT incumbent objective (live, via ZMQ)") + self.conv_plot = pg.PlotWidget(title="CP-SAT incumbent objective (live)") self.conv_plot.setLabel("bottom", "improvement index") self.conv_plot.setLabel("left", "objective (combined cost)") self.conv_plot.showGrid(x=True, y=True, alpha=0.3) @@ -921,8 +894,8 @@ def _render_map(self, chosen_by_flight: dict[str, int] | None) -> None: self.map_view.load(url) # ---------------------------------------------------------------- config -- - def _build_cfg(self, topic: str = "") -> solver_pb2.ScenarioConfig: - return solver_pb2.ScenarioConfig( + def _build_cfg(self) -> ScenarioConfig: + return ScenarioConfig( seed=self.seed_spin.value(), n_flights=self.flights_spin.value(), n_issr_blobs=self.blobs_spin.value(), @@ -933,11 +906,10 @@ def _build_cfg(self, topic: str = "") -> solver_pb2.ScenarioConfig: snapshot_window_s=SNAPSHOT_WINDOW_S, time_limit_s=self.time_spin.value(), issr_threshold=self.threshold_spin.value(), - progress_topic=topic, ) # ------------------------------------------------- problem reconstruction -- - def _rebuild_structure(self, cfg: solver_pb2.ScenarioConfig) -> None: + def _rebuild_structure(self, cfg: ScenarioConfig) -> None: """Rebuild the QUBO + conflict graph locally and refresh panels 2 & 3.""" try: world, flights, evals, conflicts, buckets = build_scenario_full(cfg) @@ -1050,8 +1022,7 @@ def _on_solve(self) -> None: if self._worker is not None and self._worker.isRunning(): return - topic = f"solve/{self.seed_spin.value()}-{int(time.time() * 1000)}" - cfg = self._build_cfg(topic) + cfg = self._build_cfg() self._pending_cfg = cfg self._rebuild_structure(cfg) diff --git a/pyproject.toml b/pyproject.toml index 1d7fa07..84ea748 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,23 +5,18 @@ build-backend = "setuptools.build_meta" [project] name = "contrail-optimization" version = "0.1.0" -description = "Contrail-aware flight-option optimizer: synthetic airspace environment + CP-SAT solver behind a gRPC/ZMQ service with a PyQt6 desktop client." +description = "Contrail-aware flight-option optimizer: synthetic airspace environment + CP-SAT and quantum solvers, with a PyQt6 desktop client." readme = "README.md" requires-python = ">=3.11" license = { text = "MIT" } authors = [{ name = "jaewonyun1234" }] -keywords = ["optimization", "cp-sat", "contrails", "grpc", "zmq", "pyqt6"] +keywords = ["optimization", "cp-sat", "contrails", "quantum", "pyqt6"] -# Core runtime = the headless solver service (gRPC + ZMQ). Deliberately does -# NOT include the PyQt6 desktop dashboard so the deployed server image stays -# small and free of GUI/X11 system dependencies. The dashboard pulls its deps -# from the `gui` extra below. +# Core runtime = the environment + CP-SAT solver. The PyQt6 desktop dashboard +# is in the `gui` extra so a headless install (tests / benchmark) stays lean. dependencies = [ "numpy>=1.26", "ortools>=9.10", - "grpcio>=1.60", - "grpcio-tools>=1.60", - "pyzmq>=25.0", ] [project.optional-dependencies] @@ -55,26 +50,22 @@ quantum = [ Repository = "https://github.com/jaewonyun1234/Flight_path_optimization_Contrail" # --------------------------------------------------------------------------- -# Packaging — two top-level packages live at the repo root. -# `service.generated` is created at build/CI time by scripts/gen_proto.sh and -# is intentionally NOT listed (it is gitignored); the editable install exposes -# the `service` directory so the generated subpackage imports fine once built. +# Packaging — contrail_env is the one installable package. The gui/ desktop +# client is run directly (python gui/app.py), not installed. # --------------------------------------------------------------------------- [tool.setuptools.packages.find] -# Auto-discover packages present in the build context: contrail_env and service. where = ["."] +include = ["contrail_env*"] # --------------------------------------------------------------------------- -# Ruff — lint the code we add in this layer. The nine pre-existing -# `contrail_env` modules are treated as vendored source (the brief forbids -# modifying them), so they are excluded; the new solver and the whole service/ -# gui/tests tree are linted normally. +# Ruff — the pre-existing `contrail_env` modules are treated as vendored source +# (the brief forbids modifying them) and excluded; new code (the scenario +# module, gui/, tests/) is linted normally. # --------------------------------------------------------------------------- [tool.ruff] line-length = 100 extend-exclude = [ "playground", # scratch / sandbox notebooks — not linted - "service/generated", "contrail_env/__init__.py", "contrail_env/demo.py", "contrail_env/units.py", @@ -92,15 +83,14 @@ select = ["E", "F", "I", "W", "UP", "B"] ignore = ["E501"] # --------------------------------------------------------------------------- -# Mypy — fully type-check the new code. The pre-existing modules cannot be -# edited, so their (best-effort) type errors are not enforced; the new -# solver_cpsat module and the service package ARE enforced. +# Mypy — fully type-check the new code (contrail_env.scenario, gui/). The +# pre-existing env modules are vendored and not enforced. # --------------------------------------------------------------------------- [tool.mypy] python_version = "3.11" ignore_missing_imports = true warn_unused_ignores = false -exclude = ["service/generated", "build/"] +exclude = ["build/"] # Pre-existing env modules are vendored (the brief forbids editing them), so # their best-effort type errors are not enforced. @@ -118,14 +108,6 @@ module = [ ] ignore_errors = true -# protoc-generated stubs use runtime metaclass magic that static analysis -# cannot see; treat the whole generated package as Any. -[[tool.mypy.overrides]] -module = ["service.generated.*"] -follow_imports = "skip" -ignore_errors = true -ignore_missing_imports = true - [tool.pytest.ini_options] testpaths = ["tests"] addopts = "-q" diff --git a/scripts/gen_proto.ps1 b/scripts/gen_proto.ps1 deleted file mode 100644 index c7c73ab..0000000 --- a/scripts/gen_proto.ps1 +++ /dev/null @@ -1,27 +0,0 @@ -# Generate the gRPC Python stubs from service/proto/solver.proto into -# service/generated/ (gitignored). PowerShell equivalent of gen_proto.sh, -# for Windows users whose shell has no `bash`. -# -# Usage (from the repo root, with your env active): -# .\scripts\gen_proto.ps1 -$ErrorActionPreference = "Stop" - -$root = Split-Path -Parent $PSScriptRoot -$out = Join-Path $root "service\generated" -$protoDir = Join-Path $root "service\proto" - -New-Item -ItemType Directory -Force -Path $out | Out-Null - -python -m grpc_tools.protoc ` - -I $protoDir ` - --python_out=$out ` - --grpc_python_out=$out ` - (Join-Path $protoDir "solver.proto") - -# Make the output an importable package whose bare `import solver_pb2` resolves -# (the generated *_pb2_grpc.py imports its sibling without a package prefix). -Set-Content -Path (Join-Path $out "__init__.py") ` - -Value "import os`nimport sys`n`nsys.path.insert(0, os.path.dirname(__file__))`n" ` - -Encoding utf8 - -Write-Output "Generated gRPC stubs in $out" diff --git a/scripts/gen_proto.sh b/scripts/gen_proto.sh deleted file mode 100755 index 4c5ffb1..0000000 --- a/scripts/gen_proto.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# Generate the gRPC Python stubs from service/proto/solver.proto into -# service/generated/ (which is gitignored — always regenerate from the .proto). -set -euo pipefail - -ROOT="$(cd "$(dirname "$0")/.." && pwd)" -OUT="$ROOT/service/generated" -PROTO_DIR="$ROOT/service/proto" - -mkdir -p "$OUT" - -python -m grpc_tools.protoc \ - -I "$PROTO_DIR" \ - --python_out="$OUT" \ - --grpc_python_out="$OUT" \ - "$PROTO_DIR/solver.proto" - -# Make the output an importable package. The generated *_pb2_grpc.py does a -# bare `import solver_pb2`, so the shim puts its own directory on sys.path, -# letting `from service.generated import solver_pb2_grpc` resolve cleanly. -cat > "$OUT/__init__.py" <<'PY' -import os -import sys - -sys.path.insert(0, os.path.dirname(__file__)) -PY - -echo "Generated gRPC stubs in $OUT" diff --git a/service/__init__.py b/service/__init__.py deleted file mode 100644 index 8e6a0b2..0000000 --- a/service/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -"""service — gRPC/ZMQ API layer over the contrail_env CP-SAT solver. - -This package exposes the classical solver as a network service: - - * proto/solver.proto — the gRPC contract (ScenarioConfig -> SolveResponse) - * generated/ — protoc-generated stubs (gitignored; see scripts/gen_proto.sh) - * progress.py — ZMQ pub/sub helpers for streaming solver progress - * server.py — async gRPC server: builds the scenario, solves, streams - * client.py — thin synchronous gRPC client used by the GUI -""" diff --git a/service/client.py b/service/client.py deleted file mode 100644 index 206a566..0000000 --- a/service/client.py +++ /dev/null @@ -1,37 +0,0 @@ -""" -client.py — Thin synchronous gRPC client for the Solver service. - -The GUI worker thread uses this to issue a blocking Solve call. The server is -async (grpc.aio) but the wire protocol is identical, so a plain synchronous -client interoperates with it without any special handling. -""" - -from __future__ import annotations - -import grpc - -from .generated import solver_pb2, solver_pb2_grpc - -DEFAULT_SERVER_ADDRESS = "localhost:50051" - - -class SolverClient: - """Wraps a gRPC channel and exposes a single typed `solve` call.""" - - def __init__(self, address: str = DEFAULT_SERVER_ADDRESS) -> None: - self.address = address - self._channel = grpc.insecure_channel(address) - self._stub = solver_pb2_grpc.SolverStub(self._channel) - - def solve(self, cfg: solver_pb2.ScenarioConfig) -> solver_pb2.SolveResponse: - """Send a ScenarioConfig and block until the SolveResponse returns.""" - return self._stub.Solve(cfg) - - def close(self) -> None: - self._channel.close() - - def __enter__(self) -> SolverClient: - return self - - def __exit__(self, *exc: object) -> None: - self.close() diff --git a/service/progress.py b/service/progress.py deleted file mode 100644 index d6e09fb..0000000 --- a/service/progress.py +++ /dev/null @@ -1,112 +0,0 @@ -""" -progress.py — ZMQ pub/sub helpers for streaming solver progress. - -The solver service publishes one small message per improved CP-SAT incumbent; -the GUI subscribes and live-plots the convergence curve. This is the -event-driven-messaging layer that decouples "the solve is making progress" -from "something is watching" — the publisher does not care whether anyone is -listening, and subscribers can come and go. - -WIRE FORMAT -=========== -Each message is a two-frame ZMQ multipart: - - frame 0 : topic (UTF-8 bytes; subscribers filter on a prefix of this) - frame 1 : payload (UTF-8 JSON: {"improvement": int, "objective": float}) - -Two frames (rather than "topic payload" in one string) means a topic -containing spaces or JSON-like characters can never corrupt parsing. - -SLOW-JOINER CAVEAT -================== -ZMQ PUB/SUB drops messages sent before a subscriber has finished connecting -and subscribing. Subscribe BEFORE triggering the solve, and tolerate the -occasional missed early frame — progress is advisory, not a source of truth -(the final SolveResponse is authoritative). -""" - -from __future__ import annotations - -import json -import threading -from collections.abc import Callable, Iterator - -import zmq - -DEFAULT_PUB_ADDRESS = "tcp://*:5556" -DEFAULT_SUB_ADDRESS = "tcp://localhost:5556" - - -# ============================================================================= -# PUBLISHER -# ============================================================================= - -class ProgressPublisher: - """Binds a ZMQ PUB socket and publishes (topic, improvement, objective). - - One publisher is bound per running server and shared across solves; - `publish` is guarded by a lock because the CP-SAT callback fires from a - worker thread and pyzmq sockets are not thread-safe. - """ - - def __init__(self, address: str = DEFAULT_PUB_ADDRESS) -> None: - self.address = address - self._ctx = zmq.Context.instance() - self._socket = self._ctx.socket(zmq.PUB) - self._lock = threading.Lock() - self._bound = False - - def bind(self) -> ProgressPublisher: - """Bind the socket. Returns self so callers can do `Publisher(addr).bind()`.""" - self._socket.bind(self.address) - self._bound = True - return self - - def publish(self, topic: str, improvement: int, objective: float) -> None: - """Send one progress event on `topic`.""" - payload = json.dumps({"improvement": int(improvement), "objective": float(objective)}) - with self._lock: - self._socket.send_multipart([topic.encode("utf-8"), payload.encode("utf-8")]) - - def close(self) -> None: - with self._lock: - self._socket.close(0) - - -# ============================================================================= -# SUBSCRIBER -# ============================================================================= - -def subscribe( - topic: str, - address: str = DEFAULT_SUB_ADDRESS, - *, - poll_timeout_ms: int = 200, - stop: Callable[[], bool] | None = None, -) -> Iterator[tuple[int, float]]: - """Yield decoded (improvement, objective) tuples published on `topic`. - - Used by the GUI worker thread. The generator blocks on a poller with a - short timeout; when idle it checks `stop()` (if given) and returns once - that becomes true. With no `stop`, it streams forever until the caller - closes the generator. - """ - ctx = zmq.Context.instance() - sock = ctx.socket(zmq.SUB) - sock.connect(address) - sock.setsockopt_string(zmq.SUBSCRIBE, topic) - - poller = zmq.Poller() - poller.register(sock, zmq.POLLIN) - try: - while True: - events = dict(poller.poll(poll_timeout_ms)) - if sock in events: - frames = sock.recv_multipart() - data = json.loads(frames[1].decode("utf-8")) - yield int(data["improvement"]), float(data["objective"]) - elif stop is not None and stop(): - return - finally: - poller.unregister(sock) - sock.close(0) diff --git a/service/proto/solver.proto b/service/proto/solver.proto deleted file mode 100644 index 0eb55ab..0000000 --- a/service/proto/solver.proto +++ /dev/null @@ -1,48 +0,0 @@ -syntax = "proto3"; -package contrail; - -// A scenario CONFIG, not a pre-built problem. The server reconstructs the -// whole contrail_env scenario from these fields, so the wire format is tiny -// and fully reproducible from (seed, sizes, weights). -message ScenarioConfig { - int32 seed = 1; - int32 n_flights = 2; - int32 n_issr_blobs = 3; - double alpha_fuel = 4; // cost weight on fuel (kg) - double beta_contrail = 5; // cost weight on contrail cells - double gamma_disruption = 6; // cost weight on disruption (FL-min) - double corridor_frac = 7; // flight clustering (default 0.02-0.25) - double snapshot_window_s = 8; // upper bound of departure window (lower = 0) - double time_limit_s = 9; // CP-SAT time limit - string progress_topic = 10; // ZMQ topic to publish progress on - double issr_threshold = 11; // RHi-excess above which a cell forms a contrail - // ISSR source. Only "synthetic" (default; "" == "synthetic") is supported. - string issr_source = 12; // "synthetic" (default) - string issr_time = 13; // reserved (unused) - double issr_p_threshold = 14; // reserved (unused) - // Flight source. Only "synthetic" (default; "" == "synthetic") is supported. - string flight_source = 15; // "synthetic" (default) - string flight_start_time = 16; // reserved (unused) - string flight_end_time = 17; // reserved (unused) -} - -message FlightChoice { - string flight_name = 1; - int32 chosen_option = 2; - double fuel_kg = 3; - int32 contrail_cells = 4; - double disruption_flmin = 5; -} - -message SolveResponse { - double objective = 1; - string status = 2; - double wall_clock_s = 3; - int32 n_conflicts = 4; - int32 n_options_total = 5; - repeated FlightChoice choices = 6; -} - -service Solver { - rpc Solve(ScenarioConfig) returns (SolveResponse); -} diff --git a/service/server.py b/service/server.py deleted file mode 100644 index 7145965..0000000 --- a/service/server.py +++ /dev/null @@ -1,190 +0,0 @@ -""" -server.py — Async gRPC server exposing the CP-SAT solver. - -The wire contract is a SCENARIO CONFIG (see proto/solver.proto): the client -sends sizes/weights/seed, and the server reconstructs the entire contrail_env -scenario from them, solves it with CP-SAT, and returns the chosen option per -flight. Because the scenario is fully seeded, the same config always yields -the same problem — which is what makes the round-trip test exact. - -The blocking CP-SAT solve runs in a thread-pool executor so it never stalls -the asyncio event loop. Progress (one event per improved incumbent) is handed -to an `on_progress` callback; Task 4 wires that callback to a ZMQ publisher. -""" - -from __future__ import annotations - -import asyncio -import os -from collections.abc import Callable - -import grpc - -from contrail_env import ( - CapacityBucket, - ConflictEdge, - CPSATResult, - EvaluatedOption, - solve_cpsat, -) - -from .generated import solver_pb2, solver_pb2_grpc -from .progress import DEFAULT_PUB_ADDRESS, ProgressPublisher -from .scenario import build_scenario_full - -DEFAULT_HOST = "localhost" -DEFAULT_PORT = 50051 - - -# ============================================================================= -# SCENARIO CONSTRUCTION + SOLVE (pure, reusable, no gRPC types) -# ============================================================================= - -def build_scenario( - cfg: solver_pb2.ScenarioConfig, -) -> tuple[list[EvaluatedOption], list[ConflictEdge], list[CapacityBucket]]: - """Solver inputs for a ScenarioConfig. - - Delegates to service.scenario (shared with the GUI) and drops the world - and flight objects, which the solver itself does not need. - """ - _world, _flights, evals, conflicts, buckets = build_scenario_full(cfg) - return evals, conflicts, buckets - - -def solve_scenario( - cfg: solver_pb2.ScenarioConfig, - on_progress: Callable[[int, float], None] | None = None, -) -> tuple[CPSATResult, int, list[EvaluatedOption]]: - """Build the scenario for `cfg` and solve it. Returns (result, n_conflicts, evals).""" - evals, conflicts, buckets = build_scenario(cfg) - result = solve_cpsat( - evals, - conflicts, - buckets, - time_limit_s=cfg.time_limit_s or 10.0, - on_progress=on_progress, - ) - return result, len(conflicts), evals - - -def make_response( - result: CPSATResult, - n_conflicts: int, - evals: list[EvaluatedOption], -) -> solver_pb2.SolveResponse: - """Pack a CPSATResult + scenario sizes into a SolveResponse message.""" - choices = [] - for i in result.chosen_eval_indices: - ev = evals[i] - choices.append( - solver_pb2.FlightChoice( - flight_name=ev.flight_name, - chosen_option=ev.option_index, - fuel_kg=ev.fuel_kg, - contrail_cells=ev.contrail_cells, - disruption_flmin=ev.disruption_FLmin, - ) - ) - return solver_pb2.SolveResponse( - objective=result.objective, - status=result.status, - wall_clock_s=result.wall_clock_s, - n_conflicts=n_conflicts, - n_options_total=len(evals), - choices=choices, - ) - - -# ============================================================================= -# GRPC SERVICER -# ============================================================================= - -class SolverServicer(solver_pb2_grpc.SolverServicer): - """Implements the Solver service. One unary RPC: Solve. - - An optional ProgressPublisher streams each improved incumbent over ZMQ. - When no publisher is supplied (e.g. in tests), progress is only logged. - """ - - def __init__(self, publisher: ProgressPublisher | None = None) -> None: - self._publisher = publisher - - def _make_progress_callback( - self, cfg: solver_pb2.ScenarioConfig - ) -> Callable[[int, float], None] | None: - """Build the per-solve progress sink bound to this request's topic.""" - publisher = self._publisher - topic = cfg.progress_topic or "solve/progress" - - def _on_progress(improvement: int, objective: float) -> None: - print(f"[progress] improvement {improvement}: objective {objective:.2f}", flush=True) - if publisher is not None: - publisher.publish(topic, improvement, objective) - - return _on_progress - - async def Solve( # noqa: N802 (gRPC method name is fixed by the proto) - self, - request: solver_pb2.ScenarioConfig, - context: grpc.aio.ServicerContext, - ) -> solver_pb2.SolveResponse: - on_progress = self._make_progress_callback(request) - - # CP-SAT is blocking; run it off the event loop. - loop = asyncio.get_running_loop() - try: - result, n_conflicts, evals = await loop.run_in_executor( - None, lambda: solve_scenario(request, on_progress) - ) - except ValueError as exc: - await context.abort(grpc.StatusCode.INVALID_ARGUMENT, str(exc)) - return make_response(result, n_conflicts, evals) - - -# ============================================================================= -# SERVER LIFECYCLE -# ============================================================================= - -async def start_server( - host: str = DEFAULT_HOST, - port: int = DEFAULT_PORT, - publisher: ProgressPublisher | None = None, -) -> tuple[grpc.aio.Server, int]: - """Create, bind, and start the server. Returns (server, bound_port). - - Pass port=0 to bind an ephemeral port (used by the test fixture); the - actually-bound port is returned. `publisher` is optional so tests can - run without binding a ZMQ socket. - """ - server = grpc.aio.server() - solver_pb2_grpc.add_SolverServicer_to_server(SolverServicer(publisher), server) - bound_port = server.add_insecure_port(f"{host}:{port}") - await server.start() - return server, bound_port - - -async def serve_forever( - host: str = DEFAULT_HOST, - port: int = DEFAULT_PORT, - progress_address: str = DEFAULT_PUB_ADDRESS, -) -> None: - publisher = ProgressPublisher(progress_address).bind() - server, bound_port = await start_server(host, port, publisher) - print(f"Solver gRPC server ready on {host}:{bound_port}", flush=True) - print(f"Publishing progress on ZMQ {progress_address}", flush=True) - await server.wait_for_termination() - - -def main() -> None: - # 12-factor config: read the bind address from the environment so the same - # image runs locally (defaults to localhost) and in a container (which sets - # CONTRAIL_GRPC_HOST=0.0.0.0 so the port is reachable from outside). - host = os.environ.get("CONTRAIL_GRPC_HOST", DEFAULT_HOST) - port = int(os.environ.get("CONTRAIL_GRPC_PORT", str(DEFAULT_PORT))) - progress_address = os.environ.get("CONTRAIL_PROGRESS_ADDRESS", DEFAULT_PUB_ADDRESS) - asyncio.run(serve_forever(host, port, progress_address)) - - -if __name__ == "__main__": - main() diff --git a/tests/test_scenario.py b/tests/test_scenario.py new file mode 100644 index 0000000..d0a8e79 --- /dev/null +++ b/tests/test_scenario.py @@ -0,0 +1,26 @@ +"""contrail_env.scenario: deterministic scenario build + in-process CP-SAT solve. + +Replaces the old gRPC round-trip test — the solve now runs in-process, so we +test the builder + solver directly with no network layer. +""" + +from contrail_env.scenario import ScenarioConfig, build_scenario_full, solve_scenario + + +def test_build_scenario_is_deterministic(): + cfg = ScenarioConfig(seed=7, n_flights=3) + _w1, _f1, e1, c1, b1 = build_scenario_full(cfg) + _w2, _f2, e2, c2, b2 = build_scenario_full(cfg) + assert [ev.flight_name for ev in e1] == [ev.flight_name for ev in e2] + assert len(c1) == len(c2) + assert len(b1) == len(b2) + + +def test_solve_scenario_returns_one_choice_per_flight(): + cfg = ScenarioConfig(seed=3, n_flights=3, time_limit_s=5.0) + seen = [] + result = solve_scenario(cfg, on_progress=lambda i, o: seen.append((i, o))) + assert len(result.choices) == 3 + assert len({c.flight_name for c in result.choices}) == 3 + assert isinstance(result.objective, float) + assert result.n_options_total >= 3 diff --git a/tests/test_server.py b/tests/test_server.py deleted file mode 100644 index dd49715..0000000 --- a/tests/test_server.py +++ /dev/null @@ -1,58 +0,0 @@ -"""gRPC round-trip: the served objective equals a direct in-process solve. - -The generated stubs only exist after `scripts/gen_proto.sh` has run (they are -gitignored), so we importorskip them — locally `pytest` skips this file if you -have not generated stubs; in CI generation runs first, so it executes. -""" - -import asyncio - -import pytest - -pytest.importorskip("service.generated.solver_pb2_grpc") - -import grpc # noqa: E402 (imported after the stub gate above) - -from contrail_env import solve_cpsat # noqa: E402 -from service.generated import solver_pb2, solver_pb2_grpc # noqa: E402 -from service.server import build_scenario, start_server # noqa: E402 - - -def _config() -> "solver_pb2.ScenarioConfig": - return solver_pb2.ScenarioConfig( - seed=1, - n_flights=3, - n_issr_blobs=8, - alpha_fuel=1.0, - beta_contrail=5.0, - gamma_disruption=0.5, - corridor_frac=0.04, - snapshot_window_s=300.0, - time_limit_s=10.0, - progress_topic="solve/test", - ) - - -async def _roundtrip(cfg: "solver_pb2.ScenarioConfig") -> "solver_pb2.SolveResponse": - # No publisher in tests -> no ZMQ socket bound, no port conflicts. - server, port = await start_server("localhost", 0) - try: - async with grpc.aio.insecure_channel(f"localhost:{port}") as channel: - stub = solver_pb2_grpc.SolverStub(channel) - return await stub.Solve(cfg) - finally: - await server.stop(0) - - -def test_server_objective_matches_direct_solve(): - cfg = _config() - resp = asyncio.run(_roundtrip(cfg)) - - # Independent direct solve on the same (fully seeded) scenario. - evals, conflicts, buckets = build_scenario(cfg) - direct = solve_cpsat(evals, conflicts, buckets, time_limit_s=10.0) - - assert resp.status == "OPTIMAL" - assert abs(resp.objective - direct.objective) < 1e-6 - assert len(resp.choices) == cfg.n_flights - assert resp.n_options_total == len(evals)