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.

StageWhatState
0Repo, licence, CIdone
1Backend, no databasedone
3React + TypeScript frontenddone — typechecks and builds
2PostgreSQL, run historydone
5Deployconfig ready — accounts not created
4Compare two runsdone — needs the owner's endpoint to light up
6Link from TARHANnot started
7S3 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 command sh 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.

VariableWhereIf you skip it
DATABASE_URLRender dashboardservice still runs, stops storing runs
CORS_ORIGINSRender dashboardthe frontend cannot reach the API
TRUST_PROXYalready 1 in render.yamlevery visitor shares one rate-limit counter
RATE_LIMIT_RUNSalready 20/minute
VITE_API_BASEVercel dashboardfrontend 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.

StageLeft openWhere
1GET /api/runs/{id}end of backend/app/main.py
2the migration that creates ix_runs_created_atbackend/alembic/versions/README.md
3the parameter formfrontend/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.py but left out of the first migration, so alembic check fails 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

MethodPathPurpose
GET/healthzliveness, and whether persistence is on
GET/api/limitsinput bounds, so the UI does not hard-code them
POST/api/runsrun an I-V sweep; stores it when a database is configured
GET/api/runsrun 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.