Skip to content
Open
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
38 changes: 38 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)

---

Expand Down
20 changes: 15 additions & 5 deletions main.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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,
Expand Down
44 changes: 44 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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/"]
26 changes: 18 additions & 8 deletions src/ai/explainer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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(
Expand Down
6 changes: 4 additions & 2 deletions src/api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand All @@ -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:
Expand Down
10 changes: 5 additions & 5 deletions src/api/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
64 changes: 38 additions & 26 deletions src/models/inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down
Loading
Loading