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 the published chart’s target-range high, interpolated between rows: 5 ppm at 20 ppm stabilizer, rising 1 ppm per 10 ppm of stabilizer to 13 ppm at 100 (the manual and saltwater columns share these highs). It is held at 10 ppm, the most any swim reading can be, except on a manually chlorinated pool from about 85 ppm stabilizer, where the 12% target itself reaches 10 and the top sits half a ppm above it. It is a display ceiling, not a safety limit — free chlorine above it after a shock is expected, not an error — and it is not the swim rule either.
- The swim rule: after a shock a test reading decides, never a clock. Nobody swims until free chlorine is at 4 ppm or the pool’s usual target, whichever is higher, and never above 10, with pH at 7.2–7.8 and the bottom visible. 10 ppm is a public-pool closure limit, not an everyday figure. During a clearing hold the overnight test comes first, and the same reading then applies. Routine top-ups keep their short mixing wait.
- 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 and a floor protects the pool itself; vinyl has nothing to dissolve, so it carries no floor at all, and on fiberglass the makers themselves disagree, so we advise rather than dose. Either way we will not sell you calcium for a shell that has not asked for it. 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 on plaster and tile: a 250 ppm floor, and a raise doses to 350 — the bottom of the ideal band in the trouble-free-pool community's published levels (250–350 acceptable, 350–550 ideal, 550–650 the acceptable maximum). Vinyl has no floor: a liner has nothing to dissolve, and the same published levels start vinyl at 0. If you tell us you are not sure what your surface is, we use no floor either: guessing plaster would have us recommending calcium chloride on the least information we have.
- Fiberglass gets an advisory, never a dose. The published levels list 250 ppm as the fiberglass minimum, but fiberglass makers disagree — some say keep calcium under 120 and add none — so below 250 we tell you what the published levels say and ask you to follow your manufacturer's care guide. We say nothing at all when pH is above 7.6 or the water is already scale-forming, because calcium on a gelcoat in that water is the risk the makers warn about.
- The calcium ceiling is 650 ppm on every surface — the top of the published acceptable maximum. Above it you get a drain plan only when the water balance (the saturation index) says the calcium is a problem, or once it is more than 50 ppm over; the plan aims for 550, is sized against your fill water's own calcium when you have told us it, and says where the rounds end. Until October 2026 this ceiling was 400, which is not a published figure.
- 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.
- Setup asks about salt twice — once as your pool TYPE and once as your SANITIZER — and if those two answers disagree we use the saltwater bands. A salt cell makes its own chlorine, so calling a salt pool "chlorine" is a fair description rather than a mistake, and the safe reading of the pair is the one that assumes the cell is there.
- pH is 7.2–7.8 targeting 7.5 on every pool — nothing about your surface or sanitizer moves it.
- The saturation index (scale or corrosion) subtracts a dissolved-solids constant from the published pool-operator table: 12.1 up to 800 ppm, 12.2 to 1,500, 12.3 to 2,900, 12.4 to 5,500 and 12.5 above. We read it at your measured salt reading. Salt is dissolved solids, so the row at your reading is the lowest your water can be in, and the index never moves further than your reading proves. A salt pool with no salt reading stays at 12.1.
- 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, formulas/saturationIndex.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 above 10 ppm — let it fall to your pool’s swim reading 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
The dose is decided before any buy link exists
The engine never sees a retailer.
Some doses carry a link to buy the product, and poolcntrl earns a commission on some of those links. That is exactly why the order is fixed: the engine works out what your water needs and which of your products to use first, with no link, price or retailer anywhere in its inputs. Links are attached afterwards, to the finished plan, and only to products the engine already chose. A link can never add a step, remove one, reorder them, or change how much your water needs to move — so "your water is fine, add nothing" stays the answer whenever it is the answer.
- The chemistry engine has no link, price or retailer in its inputs. The server attaches links only after the recommendation is complete.
- A link goes only on an action the engine already produced, for the product that action already names. No link means no change: the plan you get without commerce is the plan.
- One case restates an amount: calcium chloride is sold at different strengths, so when a link points at a specific grade the engine restates the same calcium increase in that grade’s weight. A grade we cannot confirm gets no link at all.
- If the link catalog cannot be read, the plan goes out without links rather than not going out.
In the codepackages/chemistry-engine/src/products/linkedGrade.ts, apps/api/src/affiliateLinks.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
We do not measure phosphates or borates, and that is deliberate
No published target means nothing to hold your water to.
Every reading the app asks for has a band it can hold your water to, taken from published levels. Phosphates and borates have none in the levels we follow, so there is nothing to compare a reading against and nothing to dose toward. Asking you to test them would be asking for a number we could not use. We would rather say so than invent a target.
- Phosphates: the published levels the engine follows (the trouble-free-pool community's "ideal pool levels") list no phosphate level. The app never asks for a phosphate reading and never recommends a phosphate remover. It keeps algae away with free chlorine sized to your stabilizer instead (see "Your chlorine target is set by your stabilizer").
- Borates: the same page mentions them once, for salt pools that keep scaling their cell because calcium runs high. Owners there "should consider using Borates in their water", together with lower pH and alkalinity. It gives no borate level, so the app does not ask for a borate reading or aim for a borate level. Borax does appear in the app, but only as one way to raise pH. It adds borates as it goes, and the app does not track how much has built up.
- If you have added borates, your water resists a pH change more than the app's pH estimate assumes. That is one of the reasons the estimate is deliberately set low (90% of what its model asks for) and always paired with a retest.
- Both will be added when a published source gives a target the engine can cite. Until then neither has a reading, a band or a target anywhere in the app.
In the codepackages/chemistry-engine/src/formulas/ph.ts, packages/chemistry-engine/src/targets/canonicalTargets.ts
Published sources we check against
These are the public pages our numbers are checked against. Each says which of our numbers it backs, and where we do something different.
- The Free Chlorine and Cyanuric Acid Relationship — Trouble Free Pool
What we take from it: The idea that your chlorine target rises with your stabilizer, and the 39 ppm shock level at 100 ppm stabilizer that is the most the engine will ever ask for.
Where we differ: We compute the minimum and target as a percentage of your stabilizer reading rather than reading a row off the chart, so at some stabilizer levels our minimum sits half a point away from the chart’s. - What Are My Ideal Pool Levels? — Trouble Free Pool
What we take from it: Calcium hardness: the 250 ppm floor on plaster (and tile, which we hold to the plaster row), the 350 a raise aims for, no floor on vinyl, and the 650 ppm ceiling on every surface. Stabilizer: the 30–60 ppm band on a manually chlorinated pool.
Where we differ: Not for pH or alkalinity. This page puts ideal pH at 7.6–7.8 and ideal alkalinity at 60–80; we aim for pH 7.5 and alkalinity 90 (80 on salt). - Home Pool and Hot Tub Water Treatment and Testing — U.S. Centers for Disease Control and Prevention (CDC)
What we take from it: The chlorine minimums: at least 1 ppm in a pool with no stabilizer, and at least 2 ppm once stabilizer is in the water — which is why our target never drops below 2 ppm when you have any.
Where we differ: The CDC’s pH range starts at 7.0; ours starts at 7.2. - The Model Aquatic Health Code (MAHC), 5th edition (2024) — U.S. Centers for Disease Control and Prevention (CDC)
What we take from it: The 10 ppm ceiling. The code closes a public pool when free chlorine is above 10 ppm with swimmers in it, and no pool’s swim reading here goes above that. For most home pools it is lower.
Where we differ: The code is written for public pools and spas, not back yards. We borrow this one limit from it; the rest of a home pool’s numbers come from the sources above.
poolcntrl is not affiliated with any of these publishers, and none of them has reviewed this app. They are listed so you can check our numbers against theirs. Last checked 2026-10-13.
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.