tarhan-web
August 2, 2026 · View on GitHub
Live demo and run archive for TARHAN, an open-source 1D drift-diffusion semiconductor solver (DOI 10.5281/zenodo.21761218).
This repository is not the solver. It is a thin service around it: send device parameters, get a computed I-V curve back. The physics lives in TARHAN and is pulled in as a pinned dependency.
Status
The API runs and is tested, the React frontend renders a sweep end to end, runs are persisted to PostgreSQL when a database is configured, and the deployment configuration is written and exercised locally. Nothing is deployed yet — that step needs accounts, not code. 26 tests pass.
| Stage | What | State |
|---|---|---|
| 0 | Repo, licence, CI | done |
| 1 | Backend, no database | done |
| 3 | React + TypeScript frontend | done — typechecks and builds |
| 2 | PostgreSQL, run history | done |
| 5 | Deploy | config ready — accounts not created |
| 4 | Compare two runs | done — needs the owner's endpoint to light up |
| 6 | Link from TARHAN | not started |
| 7 | S3 export (optional) | not started |
Stage 3 was done before Stage 2 on purpose: the database needs a hosted Postgres account, and the frontend does not.
The database is optional
Without DATABASE_URL the service still runs — it computes and does not store.
GET /healthz reports which mode it is in, and the run-history endpoint answers
503 rather than 500.
That is not laziness. It keeps the local demo to one command, it lets the backend
boot before a database is attached, and it keeps off distinguishable from
broken: if persistence is enabled but a write fails, the response still carries
the computed sweep, with run_id: null and persistence_error filled in.
Throwing away a successful solve because the insert failed would be worse, and
swallowing the failure silently would be worse still.
CI proves both modes — the backend job runs the suite twice, once with a PostgreSQL service container and once without it.
Deploying
The configuration is committed; the accounts are not mine to create.
- Backend → Render (
render.yaml). Blueprint, free plan, health check on/healthz, start commandsh start.sh. - Frontend → Vercel (
frontend/vercel.json). Static build, so it belongs on a CDN rather than next to the solver. - Database → Neon, or nothing at all. See above: the service runs either way.
No secret is in this repository. Every sensitive variable in render.yaml is
marked sync: false, which means Render asks for it in the dashboard and never
reads it from the file.
| Variable | Where | If you skip it |
|---|---|---|
DATABASE_URL | Render dashboard | service still runs, stops storing runs |
CORS_ORIGINS | Render dashboard | the frontend cannot reach the API |
TRUST_PROXY | already 1 in render.yaml | every visitor shares one rate-limit counter |
RATE_LIMIT_RUNS | already 20/minute | — |
VITE_API_BASE | Vercel dashboard | frontend calls its own origin and 404s |
CORS_ORIGINS has no wildcard default on purpose. Forgetting it should cost you
a broken frontend, not a wide-open endpoint.
start.sh runs alembic upgrade head only when DATABASE_URL is set. Putting
the migration directly in the start command would mean the service refuses to
boot without a database, which would contradict the design above.
Rate limiting
Every POST /api/runs runs a numerical solve, so the input bounds are not
enough on their own — they cap the cost of one request, not the number of them.
POST /api/runs is limited per client; /healthz and /api/limits are not.
Throttling the health check would have Render declare the service unhealthy and
restart it, which is a loop that feeds itself.
Behind a proxy every request arrives with the proxy's address, so the limiter
reads X-Forwarded-For — but only when TRUST_PROXY is set, because that header
is client-supplied and trusting it on a directly reachable deployment would make
the limit decorative.
Deliberately left undone
One piece per stage is left for the repository owner to write by hand, so that "I built this" survives being asked about it. Each one is off the critical path — the system works without it.
| Stage | Left open | Where |
|---|---|---|
| 1 | GET /api/runs/{id} | end of backend/app/main.py |
| 2 | the migration that creates ix_runs_created_at | backend/alembic/versions/README.md |
| 3 | the parameter form | frontend/src/components/ParameterForm.tsx |
Two of these are load-bearing rather than decorative:
- Stage 2's gap is machine-checkable. The index is declared in
app/models.pybut left out of the first migration, soalembic checkfails until the migration exists. - Stage 1's endpoint is what Stage 4 runs on. Overlaying two stored runs
needs their points, and the list endpoint deliberately does not carry them, so
the comparison UI is written against
GET /api/runs/{id}and reports a clear error until that endpoint answers.
The comparison was not shipped untested: the endpoint was implemented temporarily outside the repository, the overlay was verified in a browser, and the temporary code was deleted. It was never committed.
The validated-regime flag
TARHAN's cross-code agreement with DEVSIM 2.10.0 was measured at one specific
operating point: Na = Nd = 1e16 cm^-3, constant mobility, no SRH recombination,
V ∈ {0.1, 0.2, 0.3, 0.4} V. The measured numbers are a current-ratio deviation
of at most 8.4e-5 and a built-in potential difference of 0.5730 µV, recorded in
test_oracle_devsim.py.
Turn SRH on and the measured deviation becomes 2.7e-3 — a different regime.
This API lets you go outside that range. When you do, the solver still runs, but the response says so:
{
"within_validated_regime": false,
"validation_note": "This run is outside the cross-validated regime. ..."
}
The agreement figures are a measurement, not a guarantee that travels with the code. Attaching them to runs where they were never measured would be a false claim, so the flag is part of the response schema rather than a footnote.
Run it locally
cd backend
pip install -e ".[dev]"
pytest
uvicorn app.main:app --reload
Interactive API docs at http://127.0.0.1:8000/docs.
With the backend running, start the frontend in a second shell:
cd frontend
npm install
npm run dev
It serves on http://127.0.0.1:5173 and proxies /api to the backend, so no
absolute URL is hard-coded in the frontend.
curl -X POST http://127.0.0.1:8000/api/runs \
-H 'content-type: application/json' -d '{}'
An empty body runs the default sweep, which sits exactly on the cross-validated operating point.
Endpoints
| Method | Path | Purpose |
|---|---|---|
GET | /healthz | liveness, and whether persistence is on |
GET | /api/limits | input bounds, so the UI does not hard-code them |
POST | /api/runs | run an I-V sweep; stores it when a database is configured |
GET | /api/runs | run history, newest first; 503 when persistence is off |
GET | /api/runs/{id} | not implemented — see below |
GET /api/runs gets its point counts from a single
runs LEFT JOIN iv_points with a GROUP BY, rather than one COUNT per run.
LEFT, not INNER, so a run with no points is listed with zero rather than
vanishing from the history.
Persistence
cd backend
cp .env.example .env # then put your DATABASE_URL in it
alembic upgrade head
Two tables, deliberately not one table with a JSONB blob: runs holds the
request and the derived scalars, iv_points holds the sweep, and the foreign key
is real. validation_note is not stored — it is derived from
within_validated_regime. Storing the prose would have frozen old rows with
whatever the note said at the time, and that is not hypothetical: the note was
corrected today to remove a cross-validation claim that had no source behind it.
Rows written before that correction would still be repeating it.
How this was built
Parts of this repository were written with an AI assistant (Claude), and the
commits say so in their Co-Authored-By trailers rather than leaving it to be
guessed. Commits without that trailer are mine alone.
The working rule is that nothing gets merged that I cannot explain, and each
layer has at least one piece I write myself — GET /api/runs/{id} is mine, so
are the first migration and the parameter form. The solver being wrapped here,
TARHAN, predates this and is my own work.
Licence
AGPL-3.0-or-later. This is inherited, not chosen: tarhan-web imports TARHAN,
which is AGPL-3.0-or-later. Section 13 applies — when this service is offered
over a network, its source must be reachable by users, which is why every
response carries source_url.