# PrimerDesign — Interactive PCR Primer Design (static web app)

A dependency-free PCR primer design tool with **two engines behind one interface**:

* **Server-side Primer3 (default, recommended).** The template is posted to this site's own Flask
  service running the **real Primer3 (libprimer3, via `primer3-py` 2.4.0)**, so Tm, hairpin and dimer
  energies and the pair penalty are Primer3's own numbers. The sequence is used for the calculation
  and discarded — it is not written to disk, not logged and not shared. See §8b.
* **Built-in JavaScript engine (fallback).** Runs **entirely in the browser**, with no upload at all.
  It is used when the Primer3 service is unreachable, or when the template falls outside the service's
  length range (40–20,000 nt by default). The page returns to Primer3 by itself as soon as it can.

The front end itself is pure static HTML/CSS/JS: no database, no analytics, no third-party requests,
no API key.

Deployed path (example): `https://upon365.com/bioinfoeasy/onlinetools/PrimerDesign/`

---

## 1. Features

| Area | What it does |
| --- | --- |
| Input | Paste raw sequence or FASTA, or load a local file (`.fa .fasta .fna .txt .seq`, max 5 MB). A synthetic demo template is generated locally for a quick try. |
| Cleaning | FASTA headers removed, `U→T`, whitespace stripped, non-`ACGTN` characters removed with a visible warning. |
| Tm models | Nearest-neighbour (SantaLucia 1998, default), salt-adjusted GC formula, Wallace rule. |
| Screening | Length, GC%, Tm window, homopolymer runs, dinucleotide repeats, 3′ G/C clamp, 3′-end GC count, hairpin ΔG, self-dimer ΔG, binding-site uniqueness. |
| Pairing | Product-size window, max ΔTm, non-overlapping primers, cross-dimer ΔG and 3′-end dimer ΔG. |
| Output | Ranked table (score/penalty, sequences, Tm, ΔTm, GC, product size, plus at-a-glance hairpin & dimer risk dots) with a sticky **pair inspector** beside it: template position map (scale bar with F/R/amplicon markers and coordinates), per-primer cards, and **secondary-structure sketches** — hairpin stem–loop arc diagrams, and self-dimer / cross-dimer duplex sketches drawn on a shared column grid so that every pairing line sits exactly under the base it pairs, each with ΔG, threshold bar, stem size and the paired positions (hairpins also show dot-bracket notation). A zoomable template view (fit amplicon / whole template / pan / window presets) highlights the selected pair. Copy buttons, CSV and FASTA export. |
| Interface | English by default, one-click 中文 switch; light/dark theme; parameters and preferences persisted in `localStorage`. |
| Performance | Computation in a Web Worker with progress reporting and cancel; automatic fallback to the main thread if workers are unavailable. |
| **Two engines** | Optional **server-side Primer3** (exact thermodynamics, see §8b) with automatic fallback to the built-in JavaScript engine when the service is unreachable or the template is outside the service's length range (40–20,000 nt by default). The active engine is shown as a badge in the header, the accepted length range is shown under the input box, and the page switches back to Primer3 by itself as soon as the template fits again. |

---

## 2. File layout

```
PrimerDesign/
├── index.html                 page shell and markup
├── README.md                  this document
├── 验证-Primer3结果.md         how to prove the numbers come from Primer3 (CN)
├── assets/
│   ├── style.css              stylesheet (light + dark tokens)
│   ├── i18n.js                EN / ZH string table (also holds the in-app documentation)
│   ├── primer-core.js         built-in JS engine (pure computation, no DOM)
│   ├── primer-worker.js       Web Worker wrapper around primer-core.js
│   └── app.js                 UI wiring, rendering, export
└── server/                    optional: real Primer3 backend
    ├── primer3_api.py         Flask service around primer3-py / libprimer3
    ├── requirements.txt       primer3-py, flask, gunicorn
    ├── verify_primer3.py      end-to-end check: re-runs a request through a local primer3-py
    ├── install_bt.sh          one-shot installer (BT panel)
    ├── fix_nginx.sh           idempotent nginx location fix (backs up, nginx -t, rollback)
    ├── diag_bt.sh             collects the diagnostic output you need to paste back
    └── README_DEPLOY.md       deployment guide (BT panel + nginx + gunicorn)
```

`primer-core.js` is loaded twice on purpose: by `<script>` for the main-thread fallback, and by
`importScripts()` inside the worker. It has no DOM dependencies, so it can also be required from
Node (`module.exports`) for testing or batch use.

---

## 3. Algorithm

1. **Parse** — strip FASTA headers/comments, `U→T`, drop non-`ACGTN`, warn on `N` and on multiple records.
2. **Enumerate** — every substring of length `minLen…maxLen` on the plus strand (forward) and, on the minus
   strand, the reverse complement of every such substring (reverse).
3. **Hard filters** — length, GC%, Tm window, max homopolymer run, max dinucleotide repeat, optional 3′ G/C
   clamp, max G/C count in the last five bases.
4. **Trim** — keep the best `maxCandidates` primers per strand by penalty, so the work stays bounded
   regardless of template length.
5. **Structure & uniqueness** — hairpin ΔG, self-dimer ΔG, and the number of binding sites on the template.
   Only severe structures are rejected (`rejectHairpin`, `rejectSelfDimer`); the rest are penalised.
6. **Pair** — forward and reverse primers are combined when the product size lands inside the window,
   |ΔTm| ≤ `maxTmDiff`, and the primers do not overlap. Up to 4 partners are kept per forward primer.
7. **Dimer screen** — the best `maxPairsForDimer` pairs are screened for cross-dimer and 3′-end dimer ΔG.
8. **Rank** — by total penalty (lowest first); score = `100 − penalty`.

### Thermodynamic formulas

**Nearest neighbour (default).** SantaLucia (1998) unified parameters; ΔH in kcal/mol, ΔS in cal/(mol·K):

```
ΔH = Σ stacks + initiation(5') + initiation(3')
ΔS = Σ stacks + initiation(5') + initiation(3') [+ symmetry −1.4 if self-complementary]
```

Salt correction and melting temperature for a non-self-complementary duplex:

```
ΔS_salt = ΔS + 0.368 · (N − 1) · ln([Na⁺]eq)
Tm      = ΔH · 1000 / (ΔS_salt + R · ln(C/4)) − 273.15        R = 1.9872 cal/(mol·K)
```

`C` is the primer concentration in mol/L (the `/4` accounts for a non-self-complementary duplex at
equal strand concentrations). The monovalent cation equivalent follows Primer3:

```
[Na⁺]eq = [Na⁺] + [K⁺] + 120 · √max(0, [Mg²⁺] − [dNTP])        (mM)
```

**Salt-adjusted GC formula.** `Tm = 81.5 + 16.6·log10([Na⁺]eq) + 0.41·%GC − 500/N` (`[Na⁺]eq` in mol/L).

**Wallace rule.** `Tm = 2·(A+T) + 4·(G+C)` — only valid below roughly 14 nt; offered for comparison only.

**Secondary structure.** For every antiparallel alignment of the two sequences, contiguous complementary
stretches are scored by summing nearest-neighbour stacking free energies at 37 °C
(`ΔG = ΔH − 310.15·ΔS/1000`); the most negative stretch over all alignments is reported. Hairpins add the
constraint that at least three unpaired bases separate the two arms.

### Scoring

```
primer penalty = |Tm − Tm_opt| · 1.0
               + |GC − GC_opt| · 0.4
               + |len − len_opt| · 0.6
               + 2.0 · (maxHairpin − ΔG_hairpin)      if ΔG_hairpin  < maxHairpin
               + 1.5 · (maxSelfDimer − ΔG_self)       if ΔG_self     < maxSelfDimer
               + 2.0  if no 3′ G/C clamp
               + 1.0  if a homopolymer run ≥ 4
               + 25   if more than one binding site

pair penalty   = penalty(F) + penalty(R)
               + 1.2 · |Tm_F − Tm_R|
               + 0.01 · |product − product_opt|
               + 2.0 · (maxHeteroDimer − ΔG_cross)    if ΔG_cross    < maxHeteroDimer
               + 3.0 · (maxEnd3Dimer   − ΔG_cross)    if the 3′ end is involved

score          = max(0, 100 − pair penalty)
```

---

## 4. Parameters

| Parameter | Default | Meaning |
| --- | --- | --- |
| Primer length min / max / preferred | 18 / 27 / 21 | Accepted and ideal oligo length (nt) |
| Tm min / max / preferred | 55 / 65 / 60 | Accepted and ideal melting temperature (°C) |
| Max Tm difference | 3 | Largest allowed |Tm_F − Tm_R| |
| GC min / max / preferred | 40 / 60 / 50 | GC content window (%) |
| Product size min / max / preferred | 100 / 1000 / 400 | Amplicon length window (bp) |
| Results to show | 20 | Rows returned |
| Tm method | nearest neighbour | See §3 |
| Na⁺ / K⁺ / Mg²⁺ / dNTP | 50 / 0 / 1.5 / 0.6 mM | Reaction conditions |
| Primer concentration | 200 nM | Used in the NN Tm formula |
| Max homopolymer run | 4 | Reject `AAAAA` and worse |
| Max dinucleotide repeat | 8 | Reject `ATATATAT` and worse |
| Max G/C in last 5 bases | 3 | Limits an over-stable 3′ end |
| Require 3′ G/C clamp | on | Last base must be G or C |
| Require unique binding site | on | Primer must occur once in the template |
| Hairpin ΔG threshold | −3 kcal/mol | Soft threshold (advanced) |
| Self-dimer ΔG threshold | −6 kcal/mol | Soft threshold (advanced) |
| Cross-dimer ΔG threshold | −8 kcal/mol | Soft threshold (advanced) |
| 3′-end dimer ΔG threshold | −5 kcal/mol | Soft threshold (advanced) |
| Candidate pool size | 1200 | Primers kept per strand before structure analysis |
| Pairs to screen for dimers | 250 | Upper bound on the dimer screen |

Hard rejection is deliberately far more permissive (`rejectHairpin −9`, `rejectSelfDimer −15`) so that
borderline primers are penalised and pushed down the ranking instead of silently disappearing.

---

## 4b. Reading the results

**The table.** One row per pair, ranked by Primer3 penalty (server engine) or by score (JS engine).
The **Structure** column carries two risk dots — hairpin and cross-dimer — coloured by how far the ΔG
sits past your soft threshold, so risky pairs are visible before you click anything.

**The pair inspector** sits beside the table on wide screens (stacked below it on narrow ones) and
updates instantly as you select a row, or as you press ↑ / ↓:

| Block | What it shows |
| --- | --- |
| Header | Pair number, product size, penalty/score pill, prev/next buttons, warning tags |
| Position on template | The whole template drawn as a scale bar with the forward primer, reverse primer and amplicon marked on it (primers get a minimum drawn width so they stay visible on long templates), plus coordinates, % of template, product length and ΔTm |
| Primers | One card per oligo: sequence with the 3′ base highlighted, length, Tm, GC, position, copy button |
| Secondary structure | Hairpin (F and R) as a **stem–loop arc diagram** — bases as blocks, pairs as arcs, loop highlighted, dot-bracket notation underneath; self-dimer and cross-dimer as **antiparallel duplex sketches**, both base rows and the pairing lines drawn on one column grid (so a connector can never drift away from the bases it joins), with 5′/3′ end labels and the paired region shaded. Each block carries its ΔG, a threshold bar, the stem size and the paired positions |

**The template view** below the inspector can zoom to the amplicon, show the whole template, pan left
and right, and switch window size (120 – 5,000 bp presets). At most 5,000 bases are rendered at a time
so long templates stay responsive; the coordinates and percentage of the visible window are shown
under the sequence.

**Two provenance notes.** In server mode every ΔG shown is Primer3's, but the *geometry* of the
sketches (which bases pair, where the loop is) comes from the built-in folding model — the panel says
so under the last block. Penalty is only meaningful for the Primer3 engine; the JS engine shows its own
score, which is `100 − penalty` from §3.

**Export.** Copy buttons on each primer card, plus CSV (one row per pair, all metrics) and FASTA
(one record per oligo) in the results header.

---

## 5. Starting points for common experiments

These are not separate modes — every parameter stays editable. They are simply useful presets to type in.

| Application | Length | Tm window | GC | Product | Notes |
| --- | --- | --- | --- | --- | --- |
| Routine PCR / genotyping | 18–25 | 55–65 (opt 60) | 40–60 | 200–800 | Defaults in the shipped form |
| Cloning (with restriction tails) | 20–30 | 58–68 | 40–60 | as needed | Design on the bare template, then add tails manually — tails are not modelled |
| qPCR | 18–24 | 58–62 | 40–60 | 70–200 | Keep the product short and ΔTm ≤ 1 |
| High-GC template (> 65 %) | 20–27 | 62–70 | 45–65 | 150–600 | Raise the Tm window and add DMSO/betaine to the reaction |
| AT-rich template (< 35 %) | 22–30 | 52–60 | 30–50 | 200–800 | Longer primers are needed to reach a usable Tm |
| Long-range PCR | 22–30 | 60–68 | 45–60 | 1000–20000 | Increase `maxAmp`; verify with a high-fidelity polymerase |

Whatever the preset, always finish with an external check (Primer3 + BLAST).

---

## 6. Limitations — please read before publishing results

* **With the server engine (badge green)** the numbers are Primer3's own, so the thermodynamic
  caveats below do not apply — except that Primer3 still does not know your genome (see specificity).
* **With the built-in engine (badge amber)** this is a **fast screening approximation, not a
  re-implementation of Primer3**: Tm is identical, but the dimer/hairpin model sums nearest-neighbour
  stacks of contiguous paired stretches. It contains no loop, bulge, internal-mismatch or dangling-end
  terms, so absolute ΔG values can differ from dedicated tools by a few kcal/mol. Treat them as
  relative rankings, not as physical measurements.
* No mismatched binding is modelled, and no Owczarzy-type divalent correction is applied beyond the
  Primer3 monovalent conversion (JS engine only).
* **No genome-wide specificity check.** "Unique" means unique within the submitted template only. Always
  confirm final primers with Primer3 and a BLAST search against the target organism.
* Templates above 20,000 nt cannot be sent to the Primer3 service; that run uses the built-in engine,
  which processes long templates with a bounded candidate pool (the page says so) and may therefore
  miss locally optimal primers.
* Templates are capped at 100,000 bp to keep the browser responsive.
* The stem–loop and dimer **sketches** are always drawn by the built-in folding model, even in server
  mode — they localise *where* a structure is, they are not Primer3's secondary-structure prediction.

---

## 7. Citation, credits and references

### 7.1 Citation — please cite Primer3

Any publication that uses primers designed with this tool should cite Primer3:

> Untergasser A, Cutcutache I, Koressaar T, Ye J, Faircloth BC, Remm M, Rozen SG (2012)
> *Primer3 — new capabilities and interfaces.* **Nucleic Acids Research** 40(15):e115.
> doi:[10.1093/nar/gks596](https://doi.org/10.1093/nar/gks596)

Optional, for the interface itself:

> PrimerDesign, BioinfoEasy online toolkit,
> <https://upon365.com/bioinfoeasy/onlinetools/PrimerDesign/>, accessed YYYY-MM-DD.

The same reference is shown in the page header (as a compact credit line with links) and in the page
footer (in full), and appears in the in-app **Documentation → Citation & acknowledgements** panel, so
users cannot miss it.

### 7.2 Where the engine comes from

| Layer | Source | Licence |
| --- | --- | --- |
| Thermodynamics, penalty system (server engine) | [primer3-org/primer3](https://github.com/primer3-org/primer3) — libprimer3 | GPL v2 |
| Python binding used by `server/primer3_api.py` | [libnano/primer3-py](https://github.com/libnano/primer3-py) 2.4.0 | GPL v2 |
| Nearest-neighbour parameters (offline JS engine) | SantaLucia 1998 (see §7.3) | — |
| Interface, sketches, scoring glue | this repository | (your project licence) |

Nothing in libprimer3 is re-implemented or modified here: the template is handed to `primer3-py` and the
numbers come straight back (see §8c for how to prove it). The built-in JavaScript engine is our own code
written for this site — it follows the same nearest-neighbour framework but is a screening approximation,
not a port of Primer3.

### 7.3 References

* SantaLucia J Jr. (1998) *A unified view of polymer, dumbbell, and oligonucleotide DNA nearest-neighbor
  thermodynamics.* PNAS 95:1460–1465.
* Untergasser A, Cutcutache I, Koressaar T, et al. (2012) *Primer3 — new capabilities and interfaces.*
  Nucleic Acids Research 40:e115.
* Rozen S, Skaletsky H (2000) *Primer3 on the WWW for general users and for biologist programmers.*
  Methods in Molecular Biology 132:365–386.
* Owczarzy R, et al. (2004) *Effects of sodium ions on DNA duplex oligomers…* Biopolymers 73:361–385;
  and (2008) Biochemistry 47:5336–5353 — divalent cation corrections.
* von Ahsen N, Wittwer CT, Schütz E (2001) *Oligonucleotide melting temperatures under PCR conditions:
  deoxynucleotide triphosphate and dimagnesium ion effects.* Clinical Chemistry 47:1956–1961.

### 7.4 Acknowledgements

We thank the Primer3 developers and maintainers — Andreas Untergasser, Steve Rozen, Helen Skaletsky,
Maido Remm and colleagues — for building and maintaining libprimer3 and for releasing it under an open
licence, and the maintainers of `primer3-py` for keeping the Python binding current. All scientific credit
for the thermodynamic models, the hairpin and dimer models and the penalty system belongs to them; this
project only wraps their work in a browser interface and draws the structural sketches around it.

---

## 8. Deployment (2-core / 2 GB server)

The static part is tiny — about 40 KB of HTML/CSS/JS per visit — so a small virtual machine is more
than enough for the front end. (With the optional Primer3 service the VM also does the calculation,
which is still cheap: see §8b.)

```nginx
# /bioinfoeasy/onlinetools/PrimerDesign/
location ^~ /bioinfoeasy/onlinetools/PrimerDesign/ {
    alias /var/www/upon365.com/bioinfoeasy/onlinetools/PrimerDesign/;
    index index.html;
    try_files $uri $uri/ =404;

    # immutable assets: hash-free, so a moderate cache is safer than "immutable"
    location ~* ^(.+\.(?:css|js))$ {
        expires 7d;
        add_header Cache-Control "public, max-age=604800";
    }
}

# compression — the JS payload compresses to roughly a quarter of its size
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
```

Notes:

* Upload the whole `PrimerDesign/` directory as-is; keep the `assets/` sub-directory, since the worker
  resolves `primer-core.js` relative to its own location.
* Serving over **HTTPS is required** for the clipboard API (`navigator.clipboard`) in Chrome. Over plain
  HTTP the tool falls back to `document.execCommand('copy')`, which still works in most browsers. HTTPS
  is also required for the Primer3 service: a page served over HTTPS may not call an `http://` API.
* No PHP, Python, Node or database is needed. Do not add a `Content-Security-Policy` that blocks
  `worker-src 'self'` — the design step runs in a Web Worker.
* Memory: a 2 GB machine has plenty of headroom; nginx with 2 workers typically uses well under 100 MB
  for this workload.
* To enable the server-side Primer3 engine, add the reverse-proxy block from §8b; the static part
  above needs no change.

---

## 8b. Server-side Primer3 engine (optional, recommended)

The built-in JavaScript engine is a **fast approximation**. The `server/` directory ships a thin Flask
service around `primer3-py`, i.e. the **real Primer3 (libprimer3)**, so the same page can return
Primer3's own numbers.

### Why this matters — measured difference

Benchmarked on 40 primers / 20 pairs produced by the demo template (1,500 bp), against Primer3 2.4.0
at identical conditions (50 mM Na⁺, 1.5 mM Mg²⁺, 0.6 mM dNTP, 200 nM primer):

| Quantity | Mean signed error (JS − Primer3) | Mean absolute error | Max absolute error |
| --- | --- | --- | --- |
| Tm (nearest-neighbour) | +0.00 °C | **0.00 °C** | 0.00 °C |
| Hairpin ΔG | −0.42 kcal/mol | 0.42 kcal/mol | 2.02 kcal/mol |
| Cross-dimer ΔG | +1.51 kcal/mol | 1.51 kcal/mol | 3.67 kcal/mol |

Tm is bit-for-bit identical (the JS engine uses the same SantaLucia 1998 parameters, the same helix
initiation terms and the same monovalent-equivalent salt correction). The secondary-structure energies
are **not**, because the JS model only scores contiguous antiparallel stretches — it has no loop
closure penalty, no internal loops or bulges, and no dangling-end terms. The dimer column is the worst
case: the JS value is systematically *too positive*, i.e. it can under-report a real dimer.

### API contract

```
GET  /api/health   ->  {"ok":true,"engine":"primer3","primer3py":"2.4.0",
                        "maxSeqLen":20000,"maxReturn":100,"rateLimitPerMin":30}

POST /api/design   ->  {"ok":true,"engine":"primer3","pairs":[...],"stats":{...}}
                       body: {"sequence":"ACGT...","params":{ ...same keys as the UI... }}

POST /api/calc     ->  {"ok":true,"tm":56.93,"hairpinDg":0.0,"selfDimerDg":-5.48,...}
```

`/api/design` returns the **same pair shape** as `primer-core.js` (`f`, `r`, `tm`, `gc`, `hairpin`,
`selfDimer`, `amplicon`, `tmDiff`, `dimerDg`, `start`, `end`), so the front-end renders both engines
through one code path.

### Two conventions you must know when editing the mapping

1. **Sign of thermodynamic thresholds.** The UI expresses limits as a negative ΔG floor
   ("reject hairpins with ΔG < −3 kcal/mol"). Primer3 takes a **positive** maximum stability (−ΔG)
   and rejects negative values outright. `build_global_args()` negates every threshold
   (`maxHairpin: -3` → `PRIMER_MAX_HAIRPIN_TH: 3`) and clamps `end ≤ any`, which Primer3 requires.
   The result values are negated back to ΔG on the way out.
2. **Right-primer coordinates.** `PRIMER_RIGHT_n` is `(pos, len)` where `pos` is the plus-strand
   coordinate of the primer's **3′ base**; its 5′ base is therefore at `pos − len + 1`.
   Verified: `revcomp(template[pos−len+1 : pos+1]) == PRIMER_RIGHT_n_SEQUENCE`, and
   `PRODUCT_SIZE == right_pos − left_start + 1`.

### Performance

| Template | Primer3 engine | JS engine |
| --- | --- | --- |
| 1,500 bp | 39–59 ms | ~150 ms |
| 8,000 bp | 158 ms | — |

Real Primer3 is *faster* than the JS approximation — a 2-core / 2 GB VM handles it comfortably.
Run it as `gunicorn -w 2 -k sync --timeout 30 --bind 127.0.0.1:8787 primer3_api:app`
(≈150–200 MB resident). Full step-by-step instructions for the BT panel are in
[`server/README_DEPLOY.md`](server/README_DEPLOY.md).

### Template length limits

| Engine | Accepted template length |
| --- | --- |
| Server-side Primer3 | **40 – 20,000 nt** |
| Built-in JS engine | up to 100,000 nt (truncated above that) |

The limit comes from the service (`P3_MAX_SEQ_LEN` / `P3_MIN_SEQ_LEN`, reported by `/api/health` as
`maxSeqLen` / `minSeqLen`), so the page reads it from the server instead of hard-coding it. The input
box shows the live range underneath, and a template outside it is flagged: the length turns amber and
a warning explains that **this run** uses the built-in browser engine. Nothing is uploaded in that
case, so a 50 kb paste never wastes a round trip.

As soon as the template is back inside the range the page re-probes the service and the next run goes
to Primer3 again — no reload needed.

To raise the cap on your own server, set the environment variable and give gunicorn more time:

```bash
export P3_MAX_SEQ_LEN=50000
gunicorn -w 2 -k sync --timeout 60 --bind 127.0.0.1:8787 primer3_api:app
```

### Fail-safe behaviour

On start-up the page probes `/api/health` once (2.5 s timeout). If the service does not answer, the
badge turns amber (`JS engine`) and everything keeps working offline. If the service fails *during* a
design request, the page shows a notice and transparently falls back to the JS engine for that run.

Two different situations are handled differently, and this is the important part:

* **Service problem** (unreachable, 5xx, timeout) → the engine is downgraded to `local` and the
  fallback notice is shown. The next run re-probes `/api/health` first, so the page returns to
  Primer3 by itself once the service is back.
* **Input problem** (`sequenceTooLong`, `sequenceTooShort`, malformed payload) → the engine stays
  `server`. Only that run is computed locally, because the *service* is fine — the template just does
  not fit. Trimming the template switches back to Primer3 automatically.

---

## 8c. Verifying that results really come from Primer3

Because the page falls back silently, "it returned primers" is not proof. Three levels, quickest first:

1. **On the page** — green badge `Primer3 2.4.0`, stats line `Primer3 · … · engine 54 ms · round trip
   208 ms` (only a server run reports engine time), and the footer naming libprimer3.
2. **In the dev tools** — a server run always shows `GET /api/health` and `POST /api/design`; the
   design response carries `"engine":"primer3"` **and echoes the full Primer3 argument set** (`args`),
   which the JS engine cannot produce. No `/api/design` request = the run was local.
3. **Numerically** — re-run the identical request through a local `primer3-py` and compare:

```bash
pip install primer3-py==2.4.0
python server/verify_primer3.py                               # against the live site
python server/verify_primer3.py --base http://127.0.0.1:8787  # against your own service
python server/verify_primer3.py --seq my.fasta                # your own template
```

The script checks `/api/health`, then re-runs `design_primers` locally with the **arguments the server
echoed back** and the same sequence, then compares every primer pair and the Tm of pair #1
(±0.05 °C). Exit code 0 means all checks passed. On 2026-10-27 the live site matched a local
primer3-py 2.4.0 on 20/20 pairs and on Tm (59.704 °C).

More detail, including what each failure mode looks like, is in
[`验证-Primer3结果.md`](验证-Primer3结果.md).

---

## 9. Local testing

Open `index.html` directly, or serve the folder:

```bash
cd PrimerDesign
python -m http.server 8000     # then visit http://localhost:8000/
```

Opening via `file://` works too, but browsers refuse to start a Web Worker from `file://`; the app
detects this and computes on the main thread instead (with a brief note in the UI).

Sanity check of the engine from Node:

```bash
node -e "const C=require('./assets/primer-core.js');
console.log(C.tmNN('GTAAAACGACGGCCAGT',{na:50,k:0,mg:1.5,dntp:0.6,primerConc:200}).toFixed(2));"
// -> 56.92   (M13 forward primer, ~57 °C)
```

---

## 10. Privacy

* **Browser engine (badge amber)** — nothing leaves the machine. No analytics, no external requests,
  no cookies, no backend.
* **Primer3 engine (badge green)** — the template and the parameter values are posted to this site's
  own `/api/design` endpoint. The service holds them in memory only for the duration of the call: no
  database, no file is written, no sequence is logged, requests are rate-limited per IP (30/minute by
  default) and nothing is forwarded anywhere else. Nothing is uploaded when the template is outside the
  service's length range or the service is unreachable.
* Interface language, theme and the last used parameters are stored locally in `localStorage` under
  `upon365.primerdesign.v1` and can be cleared at any time.
* The service is GPLv2-licensed libprimer3 through `primer3-py`; this project only calls it.
