Your chlorine target is set by your stabilizer
There's no such thing as one correct chlorine number.
Cyanuric acid binds most of your free chlorine into a UV-protected reserve, and only the small unbound share is actively sanitizing. As stabilizer rises, a smaller fraction of your free chlorine is doing the work — so the target has to rise with it. A fixed target is not conservative, it's unsafe at the top end: at 80 ppm stabilizer the algae-prevention minimum alone is around 6 ppm, so a flat 4 ppm reads "fine" on a pool that is mathematically prone to algae. This is the free-chlorine-to-stabilizer relationship popularized by the trouble-free-pool community, and the engine implements it as arithmetic rather than as a lookup you have to squint at.
- Manually chlorinated pool: minimum = 7.5% of your stabilizer reading, target = 12% of it. At 40 ppm stabilizer that is a 3 ppm floor and a 5 ppm target.
- Saltwater pool: minimum = 5% of stabilizer, target = 7.5%. The cell replenishes continuously, so the same protection holds at a lower standing level.
- With any stabilizer present the target never drops below 2 ppm, no matter how low the reading.
- No stabilizer at all (0 or untested) falls back to a fixed 1 / 3 / 4 ppm band — there is no reserve to size a percentage against.
- Every value is rounded to the nearest 0.5 ppm. Finer precision than that is a fiction at test-strip or drop-kit resolution.
- The top of the displayed band is interpolated from the published chart’s target-range highs (20 ppm stabilizer → 4 ppm, 100 ppm → 10 ppm). It is a display ceiling, not a safety limit — free chlorine above it after a shock is expected, not an error.
- The scaling stops where its source stops. 39 ppm free chlorine is the highest level the published chart names for any stabilizer level, so past that the engine will not prescribe a bigger dose — it tells you to dilute instead.
In the codepackages/chemistry-engine/src/formulas/fcTarget.ts, targets/canonicalTargets.ts
Combined chlorine is total minus free — and we check your arithmetic
The reading nobody measures directly.
No consumer kit reads combined chlorine on its own. It is total chlorine minus free chlorine: the part that has already reacted with sweat, sunscreen and everything else, and is finished working. That subtraction is where the harsh "chlorine smell" comes from, and it changes what the engine recommends — water with chloramines does not need a routine top-up to the normal band, it needs a breakpoint event at a much higher level. Recommending both at once would put two chlorine doses on one screen.
- Combined = total − free, floored at zero and rounded to 0.1 ppm. If you enter a combined value yourself, the same rounding is applied to it, so no floating-point noise ever reaches a sentence you read.
- A total chlorine reading lower than your free chlorine is rejected outright as impossible — total includes free, so the engine asks you to re-check rather than dosing off it.
- If you supply all three numbers and your combined disagrees with total − free by more than 0.1 ppm, that is flagged as an inconsistent test.
- Above 0.5 ppm combined, the engine switches off the routine chlorine path and onto the breakpoint path.
In the codepackages/chemistry-engine/src/safety/validators.ts, recommendations/generateRecommendation.ts
Some bands depend on your surface and your sanitizer
A vinyl pool and a plaster pool do not want the same water.
Calcium hardness is the clearest case. Plaster is a calcium surface, so soft water pulls calcium back out of it; vinyl and fiberglass have nothing to dissolve, so the floor exists for equipment and comfort rather than for the shell. Salt systems shift things too: a cell strips carbon dioxide as water passes it, which pushes pH up continuously, and high alkalinity then buffers against every acid correction you make in response. So salt pools sit lower on alkalinity and higher on stabilizer. Every band that moves for your pool carries a written reason on screen — a number that changes without saying why reads as a bug.
- Calcium hardness floor and target: vinyl 150 / 200 ppm, fiberglass 150 / 250 ppm, plaster and tile 250 / 300 ppm. A ceiling of 400 ppm applies to every surface. An unknown surface is treated as plaster — the most demanding of them.
- Alkalinity: 60–120 ppm with a 90 ppm target on a manually chlorinated pool; 60–110 with an 80 ppm target on salt. The 60 ppm floor is the same on both and is the one number with a hard chemical reason: below roughly that, there is not enough carbonate buffer left and pH starts swinging on its own.
- Stabilizer: 30–60 ppm targeting 40 on a manual pool; 60–80 targeting 70 on salt.
- pH is 7.2–7.8 targeting 7.5 on every pool — nothing about your surface or sanitizer moves it.
- Total chlorine has no target of its own. It exists so combined chlorine can be derived from it.
In the codepackages/chemistry-engine/src/targets/canonicalTargets.ts
A reading far out of range gets fixed in stages
One big correction is the easiest way to overshoot.
When the full move from where you are to where you should be is larger than a parameter's per-dose cap, the engine does not scale the dose up. It doses toward an intermediate point, tells you that is what it did, and asks you to retest before continuing. This costs you a day and makes the correction very hard to get wrong — and it is the reason the amount you see is sometimes smaller than the arithmetic from your reading to the target would suggest.
- Alkalinity moves at most 20 ppm per dose.
- pH moves at most 0.4 per correction.
- The cap is direction-agnostic — it applies to lowering a reading exactly as it applies to raising one.
- When a dose is staged, the derivation you can open shows the staged endpoint, not the final target. The math on screen is the math that produced the amount in your hand.
In the codepackages/chemistry-engine/src/recommendations/stagedCorrection.ts, generateRecommendation.ts
Amounts are rounded to what you can actually measure
Nobody can pour 13.3333 ounces.
The chemistry formulas produce full-precision numbers. Before a dose leaves the engine it is snapped to the nearest increment a person can measure with a kitchen scale or a marked cup. Rounding is to the nearest step, not down — the amount may land slightly above or slightly below the computed figure. The unrounded number is not thrown away: it ships with the recommendation, and the app shows it when the two differ enough to be worth saying.
- Fluid ounces and weight ounces round to the nearest 0.5. Pounds and gallons round to the nearest 0.1. Tablets and bags round to whole units.
- A genuinely needed dose never rounds away to nothing — a positive amount smaller than its unit’s step becomes one step.
- Rounding is versioned separately from the chemistry, because it is a dosing rule rather than a formula, and every recommendation records which version produced its amounts.
In the codepackages/chemistry-engine/src/formulas/rounding.ts
Some readings stop the math instead of dosing it
The most useful answer is sometimes "not from this test."
Before any dose is computed, the test is run past a set of gates. Blocking gates stop the recommendation entirely; warnings let it through with the caveat attached. This is why you will occasionally get a screen full of explanation and no amount at all — the engine would rather tell you what it cannot know than dose off a number it does not trust.
- Blocking: no pool volume. Every dose is volume × concentration change; without a volume there is no arithmetic to do.
- Blocking: a test older than 72 hours. Retest before dosing.
- Blocking: a reading that fails validation — impossible values, or a total chlorine below free chlorine.
- Blocking: strip-scan readings that never went through the review screen. Confirming each pad is the human step that makes a scan usable.
- Warning: stabilizer above your own pool’s ceiling — 60 ppm on a manual pool, 80 on salt. No chemical removes stabilizer, so the honest instruction is to replace water, not to add something.
- Warning: free chlorine at or above 10 ppm — let it fall before swimming.
- Warning: pH and alkalinity both low, or both high, since fixing them in the wrong order fights itself.
- Low volume confidence is a warning, not a block — you still get the plan, with a note to verify the volume first.
In the codepackages/chemistry-engine/src/safety/gates.ts, safety/validators.ts
Deterministic, versioned formulas — AI reads, chemistry decides
Same inputs, same answer, every time. On purpose.
Every dose comes from a pure function: no randomness, no wall clock, no model call. The engine is a package of small tested functions, and each one carries a version string that is stamped onto the recommendation it produced — so a plan you saved months ago can still be traced back to the exact rules that wrote it. AI is used in this product for reading a test strip photo and for image diagnostics. It is never used to decide an amount, and a scan’s output cannot become a dose until you have confirmed it pad by pad.
- Free-chlorine targeting, chlorine dosing, alkalinity, calcium, stabilizer, salt, breakpoint, dilution, and the rounding rules each carry their own version string.
- The band payload your app renders is itself versioned and computed on the server. No client re-derives a target — the phone and the website display the same numbers because they were handed the same numbers.
- The product catalog — strengths, container sizes, safety notes — is versioned too, so "what 10% liquid chlorine means" is pinned rather than assumed.
- The free calculator on this site posts to the same engine the app uses. It is not a lighter browser-side approximation.
In the codepackages/chemistry-engine/src/index.ts, products/productCatalog.ts, targets/canonicalTargets.ts
Every dose shows its work
Open the derivation and check us.
Each recommended amount now carries the derivation that produced it, and you can open it in the app under "Why this amount?" — your volume, the parameter being moved, where it starts, where this dose lands it, the concentration change handed to the formula, the product strength divided by, the raw output, and the rounded amount you are told to pour. Those are not a re-telling: they are the actual intermediates captured at the moment the dose was computed, and no app re-multiplies them to show you the line.
- The derivation reads like the formula: volume × concentration change ÷ product strength → amount.
- The percentage is always labeled with what it measures, because 65% available chlorine, 77% purity and 10% concentration are three different claims that should not share one word.
- Both pH doses deliberately have no derivation line. The verified path is driven by an acid-demand titration in drops rather than a ppm change, and the estimate path is a multi-equilibrium model where alkalinity and stabilizer both move the answer — a tidy "gallons × ΔpH ÷ %" line there would be a plausible-looking lie. Where the arithmetic isn’t what the sentence would claim, there is no sentence.
In the codepackages/chemistry-engine/src/recommendations/doseExplain.ts, packages/shared/src/schemas/recommendation.ts
If one of these numbers looks wrong to you, that is a useful thing to tell us — the constants above are the ones the engine actually runs on, not a simplified retelling of them. You can put your own readings through the same engine on the free calculator.