From 81df512570d4bde71e3c2a55055ebcea7ab6969f Mon Sep 17 00:00:00 2001 From: Gavin Anderson Date: Tue, 12 May 2026 16:23:26 +0000 Subject: [PATCH] Phase 1 fixes: rename, CI, correctness, units, polish - Rename Sim2Real -> Sim2Sim across README, page, hero, docstrings, CSS, JS - Add GitHub Actions CI workflow (fixes broken badge) - Fix solve_base_stock dead code; correct Type-II fill-rate formula (1 - E[B per cycle] / mu_L, not E_B * D / D) - Standardize inventory lead-time to DAYS everywhere (lead_time_days param; sigma_L = sigma_d * sqrt(L_days)) for unit consistency - Add _little_law_residual: relative Little's Law diagnostic instead of absolute - Simulation: fix warm-up off-by-one; raise default warmup to max(100, N/5) - Production CORS: require ALLOWED_ORIGIN (fail loudly on empty) - Cache Anthropic client lazily at module level - Add pyproject.toml with ruff + mypy + pytest config - README polish: live demo placeholder, screenshot, monetization note - Add 7 new tests for reorder-point and base-stock (57 -> 64 passing) --- .github/workflows/ci.yml | 38 ++++++++++++++++++++++++ README.md | 8 +++-- main.py | 20 +++++++++---- pyproject.toml | 44 +++++++++++++++++++++++++++ src/ai/explainer.py | 26 +++++++++++----- src/api/routes.py | 6 ++-- src/api/schemas.py | 10 +++---- src/models/inventory.py | 64 ++++++++++++++++++++++++---------------- src/models/queuing.py | 27 +++++++++++------ src/models/simulation.py | 12 ++++---- static/css/style.css | 2 +- static/index.html | 10 +++---- static/js/api.js | 2 +- static/js/app.js | 4 +-- static/js/charts.js | 2 +- tests/test_inventory.py | 64 +++++++++++++++++++++++++++++++++++++++- 16 files changed, 265 insertions(+), 74 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 pyproject.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7d2fd3f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install ruff + + - name: Lint (ruff) + run: ruff check src tests main.py + continue-on-error: true # don't fail the build on style; just surface findings + + - name: Run tests + env: + ANTHROPIC_API_KEY: "" # explainer tests don't need a real key + run: pytest -q diff --git a/README.md b/README.md index c23c6fc..860b748 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,16 @@ -# Sim2Real — Operations Research Platform +# Sim2Sim — Operations Research Platform [![Tests](https://github.com/Gavand1969/sim2sim/actions/workflows/ci.yml/badge.svg)](https://github.com/Gavand1969/sim2sim/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![Python 3.12](https://img.shields.io/badge/python-3.12-blue.svg)](https://www.python.org/) -**Live demo:** *(deploy on Replit and paste URL here)* +**Live demo:** *(coming soon — deployment in progress)* PhD-level operations research in your browser — queuing theory, inventory optimization, Monte Carlo simulation, and linear programming with AI-generated insights powered by Claude. -Arena costs $5,000/year. AnyLogic costs $7,000/year. **This is free.** +Arena costs $5,000/year. AnyLogic costs $7,000/year. Sim2Sim is free to use and has an optional one-time **Pro** upgrade for advanced features (scenario library, Excel/PDF export, batch API). + +![Sim2Sim Screenshot](docs/screenshot.png) --- diff --git a/main.py b/main.py index 35d8f62..dee29e6 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,5 @@ """ -Sim2Real — Operations Research Simulator +Sim2Sim — Operations Research Simulator Entry point: FastAPI app that serves both the REST API and the static frontend. """ import os @@ -27,7 +27,7 @@ async def lifespan(app: FastAPI): app = FastAPI( - title="Sim2Real", + title="Sim2Sim", description="Operations Research Simulator with AI-powered explanations", version="1.0.0", docs_url="/api/docs", @@ -40,10 +40,20 @@ async def lifespan(app: FastAPI): app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) # ── CORS ────────────────────────────────────────────────────────────────────── -# In production (Replit) the frontend is served from the same origin, so -# wildcard CORS is only needed in local development. +# In production the frontend is served from the same origin, so wildcard +# CORS is only needed in local development. In production we REQUIRE an +# ALLOWED_ORIGIN so we never silently ship with no CORS policy. _env = os.getenv("ENVIRONMENT", "development") -_origins = ["*"] if _env == "development" else [os.getenv("ALLOWED_ORIGIN", "")] +if _env == "development": + _origins: list[str] = ["*"] +else: + _allowed = os.getenv("ALLOWED_ORIGIN", "").strip() + if not _allowed: + raise RuntimeError( + "ENVIRONMENT=production requires ALLOWED_ORIGIN to be set " + "(comma-separated list of allowed origins)." + ) + _origins = [o.strip() for o in _allowed.split(",") if o.strip()] app.add_middleware( CORSMiddleware, diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..50b0e48 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,44 @@ +[project] +name = "sim2sim" +version = "1.0.0" +description = "PhD-level Operations Research platform — queuing, inventory, simulation, LP, with AI insights." +readme = "README.md" +requires-python = ">=3.12" +license = { text = "MIT" } +authors = [{ name = "Gavin Anderson" }] + +[tool.ruff] +line-length = 100 +target-version = "py312" +extend-exclude = ["static/**"] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "F", # pyflakes + "I", # isort + "B", # bugbear + "UP", # pyupgrade + "SIM", # simplify +] +ignore = [ + "E501", # line too long (we already format thoughtfully) + "E731", # lambda assignment is fine + "B008", # default arg call (FastAPI uses this) +] + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["B011", "F401"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q --strict-markers" +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" + +[tool.mypy] +python_version = "3.12" +warn_return_any = false +warn_unused_configs = true +ignore_missing_imports = true +exclude = ["static/", "build/", "dist/"] diff --git a/src/ai/explainer.py b/src/ai/explainer.py index 78ea7c7..93a0a2c 100644 --- a/src/ai/explainer.py +++ b/src/ai/explainer.py @@ -26,8 +26,24 @@ _MODEL = "claude-haiku-4-5-20251001" _MAX_TOKENS = 700 +# Module-level lazy-initialised client. Created on first use so module import +# never depends on ANTHROPIC_API_KEY being set (tests stay key-free). +_client: anthropic.AsyncAnthropic | None = None + + +def _get_client() -> anthropic.AsyncAnthropic: + global _client + if _client is None: + api_key = os.getenv("ANTHROPIC_API_KEY", "") + if not api_key: + raise EnvironmentError( + "ANTHROPIC_API_KEY is not configured. Add it to your .env file." + ) + _client = anthropic.AsyncAnthropic(api_key=api_key) + return _client + _SYSTEM_PROMPT = """\ -You are a PhD-level operations research expert embedded in Sim2Real, \ +You are a PhD-level operations research expert embedded in Sim2Sim, \ a professional OR platform. You explain quantitative results to engineers, \ analysts, and students. @@ -89,13 +105,7 @@ async def explain( EnvironmentError if ANTHROPIC_API_KEY is not set. anthropic.APIError on API-level failures (caller handles). """ - api_key = os.getenv("ANTHROPIC_API_KEY", "") - if not api_key: - raise EnvironmentError( - "ANTHROPIC_API_KEY is not configured. Add it to your .env file." - ) - - client = anthropic.AsyncAnthropic(api_key=api_key) + client = _get_client() user_message = _build_user_message(model_type, parameters, results) message = await client.messages.create( diff --git a/src/api/routes.py b/src/api/routes.py index ba559b2..50ec92b 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -217,7 +217,8 @@ async def newsvendor_endpoint(body: NewsvendorRequest): async def reorder_point_endpoint(body: ReorderPointRequest): try: r = solve_reorder_point( - D=body.annual_demand, L=body.lead_time, sigma_d=body.demand_std_day, + D=body.annual_demand, L_days=body.lead_time_days, + sigma_d=body.demand_std_day, K=body.ordering_cost, c=body.unit_cost, i=body.holding_rate, service_level=body.service_level, ) @@ -239,7 +240,8 @@ async def reorder_point_endpoint(body: ReorderPointRequest): async def base_stock_endpoint(body: BaseStockRequest): try: r = solve_base_stock( - D=body.annual_demand, L=body.lead_time, sigma_d=body.demand_std_day, + D=body.annual_demand, L_days=body.lead_time_days, + sigma_d=body.demand_std_day, c=body.unit_cost, i=body.holding_rate, service_level=body.service_level, ) except Exception: diff --git a/src/api/schemas.py b/src/api/schemas.py index 385fb45..5dc8372 100644 --- a/src/api/schemas.py +++ b/src/api/schemas.py @@ -195,8 +195,8 @@ class NewsvendorResponse(BaseModel): class ReorderPointRequest(BaseModel): annual_demand: float = Field(..., gt=0, le=10_000_000, description="D: annual demand (units/year)") - lead_time: float = Field(..., gt=0, le=5, description="L: replenishment lead time (years)") - demand_std_day: float = Field(..., ge=0, le=100_000, description="σ_d: std dev of daily demand") + lead_time_days: float = Field(..., gt=0, le=730, description="L: replenishment lead time, in DAYS (max 730 = 2 years)") + demand_std_day: float = Field(..., ge=0, le=100_000, description="σ_d: std dev of DAILY demand (must match lead-time unit)") ordering_cost: float = Field(..., gt=0, le=1_000_000) unit_cost: float = Field(..., gt=0, le=100_000) holding_rate: float = Field(..., gt=0, le=1) @@ -219,9 +219,9 @@ class ReorderPointResponse(BaseModel): # ── Inventory: Base Stock ───────────────────────────────────────────────────── class BaseStockRequest(BaseModel): - annual_demand: float = Field(..., gt=0, le=10_000_000) - lead_time: float = Field(..., gt=0, le=5) - demand_std_day: float = Field(..., ge=0, le=100_000) + annual_demand: float = Field(..., gt=0, le=10_000_000, description="D: annual demand (units/year)") + lead_time_days: float = Field(..., gt=0, le=730, description="L: replenishment lead time, in DAYS") + demand_std_day: float = Field(..., ge=0, le=100_000, description="σ_d: std dev of DAILY demand") unit_cost: float = Field(..., gt=0, le=100_000) holding_rate: float = Field(..., gt=0, le=1) service_level: float = Field(0.95, gt=0, lt=1) diff --git a/src/models/inventory.py b/src/models/inventory.py index de3e82b..3612b44 100644 --- a/src/models/inventory.py +++ b/src/models/inventory.py @@ -282,27 +282,32 @@ class ReorderPointResult: def solve_reorder_point( - D: float, # annual demand - L: float, # lead time (years) - sigma_d: float, # std dev of daily demand (use same time unit as L) - K: float, # ordering cost - c: float, # unit cost - i: float, # holding rate + D: float, # annual demand (units/year) + L_days: float, # lead time in DAYS + sigma_d: float, # std dev of DAILY demand (same time unit as L_days) + K: float, # ordering cost ($/order) + c: float, # unit cost ($/unit) + i: float, # holding rate (fraction/year) service_level: float = 0.95, # target cycle-service level ) -> ReorderPointResult: """ Continuous-review (Q, r) inventory policy. + Units: annual demand D is converted to a daily rate (D/365) for the + lead-time demand calculation, ensuring lead-time-day and daily-sigma + units agree. Holding cost h = i·c is annual, so the EOQ uses annual D. + Order quantity Q = EOQ (Wilson formula). - Reorder point r = D·L + z·σ_L where σ_L = σ_d·√L (annual units). + Reorder point r = (D/365)·L_days + z·σ_L, σ_L = σ_d·√L_days. The cycle-service level P(no stockout) = Φ(z). Safety stock SS = z·σ_L trades holding cost against stockout risk. """ h = i * c - Q = math.sqrt(2.0 * K * D / h) # EOQ order quantity - mu_L = D * L # mean demand during lead time - sigma_L = sigma_d * math.sqrt(L) # std dev demand during lead time + Q = math.sqrt(2.0 * K * D / h) # EOQ order quantity (annual) + D_day = D / 365.0 # daily demand rate + mu_L = D_day * L_days # mean demand during lead time (units) + sigma_L = sigma_d * math.sqrt(L_days) # std dev demand during lead time (units) z = stats.norm.ppf(service_level) SS = z * sigma_L r = mu_L + SS @@ -336,26 +341,30 @@ class BaseStockResult: def solve_base_stock( - D: float, # annual demand rate - L: float, # replenishment lead time (years) - sigma_d: float, # std dev of daily demand - c: float, # unit cost - i: float, # holding rate + D: float, # annual demand rate (units/year) + L_days: float, # replenishment lead time in DAYS + sigma_d: float, # std dev of DAILY demand + c: float, # unit cost + i: float, # annual holding rate service_level: float = 0.95, ) -> BaseStockResult: """ Base-stock (order-up-to) policy for single-item, stochastic demand. - S* = μ_L + z·σ_L (same expression as reorder point). + Units: lead-time is in days and daily sigma is supplied directly, so all + lead-time quantities (μ_L, σ_L) are in CONSISTENT day-based units. + + S* = μ_L + z·σ_L. Each period, order enough to bring inventory position back to S*. - E[backorders] = σ_L · L(z) where L(z) = φ(z) − z·(1−Φ(z)) is the - standard normal loss function. - Fill rate ≈ 1 − E[B]/D (Type-II service level). + E[backorders per cycle] = σ_L · L(z) where + L(z) = φ(z) − z·(1−Φ(z)) is the standard-normal loss function. + Type-II fill rate = 1 − E[B per cycle] / E[demand per cycle] = 1 − σ_L·L(z)/μ_L. """ h = i * c - mu_L = D * L - sigma_L = sigma_d * math.sqrt(L) + D_day = D / 365.0 + mu_L = D_day * L_days + sigma_L = sigma_d * math.sqrt(L_days) z = stats.norm.ppf(service_level) SS = z * sigma_L S = mu_L + SS @@ -364,12 +373,15 @@ def solve_base_stock( Phi_z = stats.norm.cdf(z) L_z = phi_z - z * (1.0 - Phi_z) # standard normal loss function - E_B = sigma_L * L_z + E_B = sigma_L * L_z # expected backorders per cycle (units) E_inv_precise = (S - mu_L) * Phi_z + sigma_L * phi_z - fill_rate = max(0.0, 1.0 - E_B * D / D) if D > 0 else 1.0 - # Type-II fill rate: 1 − E[B] / (demand per period) - # For annual: fill_rate = 1 − E_B/D (E_B and D in same annual units) - fill_rate = float(np.clip(1.0 - E_B / D, 0.0, 1.0)) if D > 0 else 1.0 + + # Type-II (fill-rate) service level. + # The standard expression for a base-stock policy with normal lead-time + # demand is: beta = 1 - E[B per cycle] / E[demand per cycle] + # With a continuous-review base-stock policy the demand per cycle equals + # the lead-time demand mu_L (one cycle = one lead-time interval), so: + fill_rate = float(np.clip(1.0 - E_B / mu_L, 0.0, 1.0)) if mu_L > 0 else 1.0 annual_hold = h * E_inv_precise return BaseStockResult( diff --git a/src/models/queuing.py b/src/models/queuing.py index 6c58357..1baf33b 100644 --- a/src/models/queuing.py +++ b/src/models/queuing.py @@ -42,6 +42,15 @@ class QueuingResult: # ── Internal helpers ────────────────────────────────────────────────────────── +def _little_law_residual(L: float, lam: float, W: float) -> float: + """Relative residual |L − λW| / max(|L|, 1). + Using a relative scale keeps the diagnostic meaningful at high + utilisation where L can be very large in absolute terms. + Values near 0 confirm Little's Law holds for this model. + """ + return abs(L - lam * W) / max(abs(L), 1.0) + + def _erlang_c(c: int, a: float) -> float: """ Erlang-C probability C(c, a) = P(arriving customer must wait). @@ -89,7 +98,7 @@ def solve_mm1(lam: float, mu: float) -> QueuingResult: return QueuingResult( model="M/M/1", utilization=rho, L=L, Lq=Lq, W=W, Wq=Wq, P0=1.0 - rho, P_wait=None, prob_dist=_mm1_prob_dist(rho), - little_law_check=abs(L - lam * W), + little_law_check=_little_law_residual(L, lam, W), ) @@ -110,7 +119,7 @@ def solve_mmc(lam: float, mu: float, c: int) -> QueuingResult: return QueuingResult( model=f"M/M/{c}", utilization=rho, L=L, Lq=Lq, W=W, Wq=Wq, P0=P0, P_wait=ec, prob_dist=_mmc_prob_dist(c, a, P0), - little_law_check=abs(L - lam * W), + little_law_check=_little_law_residual(L, lam, W), ) @@ -126,7 +135,7 @@ def solve_md1(lam: float, mu: float) -> QueuingResult: return QueuingResult( model="M/D/1", utilization=rho, L=L, Lq=Lq, W=W, Wq=Wq, P0=1.0 - rho, P_wait=None, prob_dist=_mm1_prob_dist(rho), - little_law_check=abs(L - lam * W), + little_law_check=_little_law_residual(L, lam, W), ) @@ -146,7 +155,7 @@ def solve_mg1(lam: float, mu: float, cs_sq: float) -> QueuingResult: return QueuingResult( model=f"M/G/1 (Cs²={cs_sq:.2f})", utilization=rho, L=L, Lq=Lq, W=W, Wq=Wq, P0=1.0 - rho, P_wait=None, prob_dist=_mm1_prob_dist(rho), - little_law_check=abs(L - lam * W), + little_law_check=_little_law_residual(L, lam, W), ) @@ -192,7 +201,7 @@ def solve_mmck(lam: float, mu: float, c: int, K: int) -> QueuingResult: return QueuingResult( model=f"M/M/{c}/{K}", utilization=rho, L=L, Lq=Lq, W=W, Wq=Wq, P0=P0, P_wait=None, prob_dist=prob_dist, - little_law_check=abs(L - lam_eff * W), + little_law_check=_little_law_residual(L, lam_eff, W), blocking_prob=P_K, effective_lam=lam_eff, ) @@ -229,7 +238,7 @@ def solve_mm1k(lam: float, mu: float, K: int) -> QueuingResult: return QueuingResult( model=f"M/M/1/{K}", utilization=util, L=L, Lq=Lq, W=W, Wq=Wq, P0=P0, P_wait=None, prob_dist=prob_dist, - little_law_check=abs(L - lam_eff * W), + little_law_check=_little_law_residual(L, lam_eff, W), blocking_prob=P_K, effective_lam=lam_eff, ) @@ -258,7 +267,7 @@ def solve_mminf(lam: float, mu: float) -> QueuingResult: return QueuingResult( model="M/M/∞", utilization=rho, L=L, Lq=Lq, W=W, Wq=Wq, P0=P0, P_wait=0.0, prob_dist=prob_dist, - little_law_check=abs(L - lam * W), + little_law_check=_little_law_residual(L, lam, W), notes="No queuing: every customer finds a free server immediately.", ) @@ -289,7 +298,7 @@ def solve_gg1(lam: float, mu: float, ca_sq: float, cs_sq: float) -> QueuingResul model=f"G/G/1 (Ca²={ca_sq:.2f}, Cs²={cs_sq:.2f})", utilization=rho, L=L, Lq=Lq, W=W, Wq=Wq, P0=1.0 - rho, P_wait=None, prob_dist=_mm1_prob_dist(rho), - little_law_check=abs(L - lam * W), + little_law_check=_little_law_residual(L, lam, W), notes="Kingman heavy-traffic approximation. Most accurate when ρ > 0.7.", ) @@ -322,7 +331,7 @@ def solve_bulk_mm1(lam: float, mu: float, batch_size: int) -> QueuingResult: return QueuingResult( model=f"M[{b}]/M/1", utilization=rho, L=L, Lq=Lq, W=W, Wq=Wq, P0=1.0 - rho, P_wait=None, prob_dist=_mm1_prob_dist(rho), - little_law_check=abs(L - lam_ind * W), + little_law_check=_little_law_residual(L, lam_ind, W), notes=f"Batches of {b} arrive at rate λ={lam}. " f"Individual arrival rate = {lam_ind:.2f}.", ) diff --git a/src/models/simulation.py b/src/models/simulation.py index fc7be55..657c71c 100644 --- a/src/models/simulation.py +++ b/src/models/simulation.py @@ -63,8 +63,9 @@ def _run_single_replication( we can compute each customer's arrival, queue-entry, and departure analytically without maintaining a full event heap. """ - # Burn-in: discard first 10% of customers to reach steady state - n_warmup = max(1, n_customers // 10) + # Burn-in: discard first 20% of customers (or at least 100) to reach + # steady state. 10% was too aggressive for ρ > 0.8 systems. + n_warmup = max(100, n_customers // 5) n_total = n_customers + n_warmup server_free = np.zeros(c) # time each server becomes free @@ -73,7 +74,7 @@ def _run_single_replication( wait_times = [] # Wq per customer (post warmup) sojourn = [] # W per customer (post warmup) busy_total = 0.0 # total server-busy time (post warmup) - sim_start = None # clock at end of warmup + sim_start = None # clock at start of measurement window for k in range(n_total): # Inter-arrival: Exponential(lam) @@ -95,8 +96,9 @@ def _run_single_replication( wq = start_svc - arrival # wait in queue w = depart - arrival # sojourn time - if k == n_warmup - 1: - # Mark steady-state clock start and reset server busyness + if k == n_warmup: + # First post-warmup customer: this arrival starts the + # measurement window for utilisation accounting. sim_start = arrival busy_total = 0.0 diff --git a/static/css/style.css b/static/css/style.css index 12c7f3d..15191e6 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1,5 +1,5 @@ /* ════════════════════════════════════════════════════════ - Sim2Real — Dark Professional Theme + Sim2Sim — Dark Professional Theme Design tokens → component styles → layout → utilities ════════════════════════════════════════════════════════ */ diff --git a/static/index.html b/static/index.html index ddc869a..96db68f 100644 --- a/static/index.html +++ b/static/index.html @@ -3,8 +3,8 @@ - - Sim2Real | Operations Research Platform + + Sim2Sim | Operations Research Platform @@ -33,7 +33,7 @@
- Sim2Real + Sim2Sim Operations Research