FPL hassanahmad.dev
GW– deadline–

METHOD

The whole model, written out. Numbers in green are your current dial settings, read live from this browser — if you have moved a dial, this page describes your model, not mine.

The projection



  

Expected minutes

This is the v0.10 change, and it is the largest one. Until v0.10 every rate in the model came from history_past — last season. At GW1 that was the only prior there was and it was correct. By GW5 it was stale, and it failed in one visible way: a player who had stopped playing still projected on last season's per-90 rates, because nothing in the pipeline knew his minutes had collapsed. Senesi played 90 minutes in GW1 and none since; he is fit, he is in the squad, and he is not being picked. Last season said he starts.

p_start now comes from this season's per-gameweek starts, recency-weighted over a four-gameweek window — last week counts 1, the week before 0.75, then 0.56, then 0.42 — and clamped to [0.05, 0.95]. Nobody is certain to start and nobody with four blanks is certainly finished; a point estimate off four games must not be either. E[mins|start] and E[mins|sub] come from the whole season on record rather than the window, because is he being picked is a fact about now while how long does he last is a fact about him and wants the bigger sample.

A gameweek his club did not play is dropped, not counted as zero. That covers a blank gameweek and also a fixture that has not kicked off yet — without it, every player at both clubs in a Monday-night fixture would read as having been left out on the Saturday.

The gate is applied after the estimate, and availability overrides history. The five rows are in the formula above. The last of them is the Senesi case stated explicitly: three gameweeks without a minute, while carrying no injury flag at all. History says he plays; the team sheet says otherwise, three weeks running. 0.25 is a guess, which is exactly why it is a dial — and the backtest is the only thing with standing to move it.

manual_override is a human input, not a model output. It defaults to 1.0 and it is still the most valuable control on the site. This is the term where the paid tools beat this one: they buy press-conference and training-ground reporting and turn it into a starting probability. I can't, so rather than pretend the model knows, the dial is handed to you — tap any player on THE PAGE and set it. It multiplies p_start, so expected minutes and all four components move with it.

The blend: last season is a prior, not a fact

w = min(1, minutes_this_season ÷ 500), and every rate the model uses is w × this season + (1 − w) × last season. At GW5 a four-from-four starter sits at w ≈ 0.72; a rotation player stays anchored to his prior, which is right — we have not learned much about him yet. At w = 0 the rates are exactly v0.9.2's, which is what makes the change auditable: the blend cannot be blamed for a difference that is really somewhere else.

Three edge cases, each with a test. A player with no history_past at all — promoted side, new signing, youth debut — blends against the positional median rather than against zero. A player with no minutes this season sits at w = 0 on the pure prior. A player with neither falls back to the unknown-player start prior dial. Every player's w is published and is a sortable column on THE BOARD, so you can see how much of any projection is memory and how much is measurement.

Position reclassification is not detected, and this is a known gap. A player reclassified between seasons keeps blending last season's rates, which were earned in a different role. Last season's element_type is published nowhere in the public API, and reconstructing it from last season's points does not work — because history_past omits defensive-contribution points, the reconstruction bracket is wide enough that every position fits 342 of 343 players. Rather than infer it from a guess, it is left undetected and said so here.

manual_override is a human input, not a model output. It defaults to 1.0 and it is the most valuable control on the site. This is the term where the paid tools beat this one: they buy press-conference and training-ground reporting and turn it into a starting probability. I can't, so rather than pretend the model knows, the dial is handed to you — tap any player on THE PAGE and set it. Everything downstream updates. If a projection here looks wrong, this is almost always the term that is wrong.

Appearance, attack, defence, bonus

Expected minutes scale all four of them, and in v0.9.2 they did not. That version multiplied the whole bracket by P(start) and then let attack and bonus run at a full ninety minutes' rate for anyone who started. A man who plays twenty minutes does not accrue attacking returns at his per-90 rate, and the old formula said he did. Each term now takes the minutes the way the scoring rules actually work:

appearance = 2 × P(60+) + 1 × P(played, under 60). Not scaled by minutes, because it is already a points expectation — a 75-minute cameo and a full 90 are both worth two.

attack is xG90 × the goal value for the position (GK 10, DEF 6, MID 5, FWD 4) plus xA90 × 3, scaled by expected minutes, the attack dial and the fixture ramp. xG and xA, not goals and assists — finishing luck is the largest single source of noise in a five-gameweek sample and it does not persist, so carrying it forward is carrying noise with a confident face on it. A player on twelve goals from 0.2 xG a game projects below one on no goals from 0.8.

defence is P(clean sheet) × P(60+) × clean-sheet points (GK 4, DEF 4, MID 1, FWD 0), plus the defensive-contribution term, plus saves ÷ 3 for keepers scaled by expected minutes. The clean sheet is multiplied by P(60+) rather than by minutes because the game requires the hour for it.

P(clean sheet) is a Poisson zero on this season's team expected goals conceded: λ = team xGC per match ÷ the fixture multiplier, and P(cs) = exp(−λ). A club's xGC for a match is derived as its opponents' xG, summed over their players — a measurement of this season rather than a pre-season defensive-strength rating. Four matches in, that is a thin sample, so it is shrunk toward the league mean with a stated pseudo-count of two matches: λ = (matches × own + 2 × league) ÷ (matches + 2). Deliberately one line a reader can check by hand rather than a fitted curve.

One λ serves both the clean sheet and the goals-conceded deduction. v0.9.2 had a clean-sheet multiplier and a separate 2 − cm hack pointing the other way, which could and did disagree with itself at the edges of the ramp.

The defensive-contribution term was wrong, and the API is what settled it. defensive_contribution is published per player this season. Checking it against its parts shows the game uses two formulas: tackles + clearances, blocks and interceptions for defenders, tackles + recoveries + CBI for midfielders and forwards, and nothing at all for keepers. v0.9.2 used the midfielder formula for every position, so every defender was credited with his recoveries against a threshold of ten — a systematic overrate of exactly the players a defensive-contribution term is meant to reward. The thresholds were right; the input to them was not.

And the threshold is a count per match, so expected minutes go inside the probability rather than scaling it afterwards. v0.9.2 multiplied a full-match probability by minutes share, which says a twenty-minute substitute has 22% of a starter's chance of making twelve defensive actions. He has about half that.

bonus is BPS per 90 converted to realised bonus points. The work order specified a flat 0.02; measuring the conversion on last season instead gives . Forwards convert at roughly double everyone else's rate because they bank BPS in goal-shaped bursts, which lands them in the top three far more often per BPS accumulated. The dial scales that measured conversion rather than replacing it.

Form, price and transfers are display columns, not terms

THE BOARD carries form, points per game, price change this gameweek, price change on the season, transfers in and out this gameweek, and selected-by percentage. All of them are sortable. None of them is in the projection, and the reasons are different for each.

Form is points per game over the last 30 days — realised points. It contains exactly the finishing luck that switching the attack term to xG exists to remove. Worse, it double-counts: a player "in form" is usually a player who is playing and getting chances, and both of those are already in the projection on their own terms, measured properly. Adding form on top would weight them twice and call the result new information.

Price movement predicts price. It is driven by net transfers, which are driven by what other managers are doing, which is a fact about the market and not about the fixture. It belongs to team value — a different objective, with its own panel on THE PAGE.

Transfers in and out predict price and ownership. Those matter for rank strategy, and rank strategy is what the differential dial is for. They do not predict points.

None of this means the columns are worthless — they answer real questions, which is why they are on the board. They answer questions other than "how many points will he score".

There is a form_weight dial and it is 0.0. The hook exists so the question can be settled with evidence from the backtest rather than argued about, and it does not get turned on in this version. A test asserts the default is 0.0, and a second test poisons a component row with a form of 9.9 and asserts the projection does not move — because a dial that is documented as inert and is quietly wired to something is worse than no dial.

The fixture ramp

Derived from the difficulty of the opponent, taken from the fixtures endpoint, then adjusted for venue.

Rather than expose ten separate ramp values, the fixture swing dial scales the whole ramp around 1.0: at 0 fixtures are ignored entirely, at 1 you get the published ramp, at 2 its swing is doubled. Goals conceded use the mirror of the clean-sheet multiplier.

Where this departs from the work order

Four changes, all of them forced by the calibration check, all of them reversible with a dial:

  1. Deductions were missing entirely. The specified formula has no term for goals conceded (−1 per 2 for keepers and defenders) or cards. Projecting last season without them put defenders +20% and keepers +19% over their actual totals — almost exactly the size of the missing deduction. Adding the term took mean absolute error from 22.6 points to 13.6 and took the positional bias on both to under 2%.
  2. The 0.90 attack haircut is not supported. At 0.90 every position projected light. The default is 1.00. The dial is still there at 0.90 if you disagree.
  3. Bonus conversion is measured per position rather than assumed flat, as above.
  4. Defensive contribution is rebuilt from its components. history_past reports defensive_contribution as 0 even for seasons where the stat existed, so the rate is reconstructed from tackles + recoveries + clearances/blocks/interceptions and passed through a logistic to get the probability of clearing the threshold (10 actions for defenders, 12 for everyone further forward) in a full match.

What the model does not know

Evidence behind each projection

Every row in projections.json carries a prior_confidence value, and every surface in the site shows it. A projected 4.2 built from a flat fallback must not render identically to a projected 4.2 built from 38 games — that is a presentation bug, not a maths bug, and it is the one that costs trust.

Which clubs have no Premier League priors at all: Their players have an empty history_past, so they carry a positional median and a flat 0.50 start prior. The optimiser does not pick them on evidence — it picks them because they are cheap and its guess is indistinguishable from real knowledge. Set the override yourself, or treat those projections as placeholders.

The flat prior outranks real evidence, and that is a problem

This surfaced from a case where the answer was already known. The squad started a Coventry defender away at Arsenal while an Arsenal striker sat on the bench at home to Coventry — the model appearing to project a Coventry clean sheet and no Arsenal return in the same match. It does flag that: the free-lineup section moves the striker into the XI.

But chasing it down found something worse. Two Coventry defenders in the squad carry byte-identical projections — same start probability, same expected points, because both are the same flat guess wearing two names. And that guess, at 0.50, outranks a Hull defender whose 0.13 start probability is real evidence drawn from a genuine, thin Premier League record. Ignorance is beating knowledge, and it is beating it in the direction that makes cheap unknown defenders look like bargains.

Rather than quietly pick a more flattering number, the assumption is now the unknown-player start prior dial. Drag it down and every NO PRIOR row falls together. At 0.00 the model refuses to guess at all and those players vanish from contention; at 0.50 it treats an unknown as a coin flip. There is no correct value here — that is the honest finding, and putting a slider on it is the only truthful way to render it.

The optimiser is not the model

The projection answers how many points will this player score. The optimiser answers which fifteen, and it has dials of its own that are not model weights.

bench_weight (default ) is what a bench point is worth relative to a starting point. It is deliberately neither 0 nor 1. At 1.0 — bench points scoring in full — the solver parks £7.0m midfielders on the bench and the XI gets £5m cheaper. At 0.0 it fills the bench with the cheapest bodies in the game, which is its own failure: bench players are real insurance, because they auto-sub when a starter does not play. Measured on the current player pool:

Two of your players are facing each other

The site flags this. The optimiser ignores it, and that is deliberate.

Expected points add regardless of correlation: E[A+B] = E[A] + E[B] whether or not A and B are opposed. What a clash changes is variance, not the mean. Two players facing each other is a hedge — one team's clean sheet is the other's blank — so it narrows the range of outcomes. Two players on the same attacking side is the opposite: their good weeks coincide, and the range widens.

Since the goal is beating a field of millions rather than scoring reliably, narrow is not obviously desirable. A squad that reliably scores the average reliably finishes mid-table. So the flag exists to make the choice visible, not to make the solver avoid it. When the ownership dial lands, this becomes something to tune on purpose rather than something to notice.

The entry itself

What the real team actually scored and where it ranked, alongside the three tracks above. This is here for the record, not to replace the FPL app — no live scores, no mini-leagues, no price tracking.

Calibration

The model run over last season and compared with what actually happened. This is in-sample by construction, so it is not an accuracy claim — it only asks whether the multipliers put points on the right scale. A ratio of 1.00 means the projection is neither generous nor stingy on average.

MODEL plays by the rules

Until v0.4 the model track re-solved all fifteen players from scratch every gameweek. That is a free wildcard every week. It never paid a −4, never carried a bad pick, and never had to unwind a decision it made under worse information. Against a transfer-constrained human it would have won indefinitely and the result would have meant nothing.

MODEL now carries one squad across the season in data/model_state.json: fifteen players, a bank, a free-transfer count, and its cumulative hits. Each gameweek it solves for the best squad reachable from the one it already owns, with the −4 inside the objective — so it takes a hit only when the hit pays back over the horizon. Selling returns only half of any price rise, as in the real game. The free-transfer cap is read from game_settings.max_extra_free_transfers rather than hardcoded, because FPL has changed it before.

State is derived from the frozen record, never maintained alongside it: advancing the squad means reading the committed prediction files forward. State cannot drift from the record because the record is its only source.

Two decisions, two horizons. Buying the fifteen costs a transfer and possibly a −4, and the squad you buy has to last, so it is solved over the four-gameweek horizon. The XI, the armband and the bench order cost nothing and are remade every single week, so since v0.6.1 they are solved on this gameweek alone, after the squad is fixed. Until then both came out of one solve against the horizon, and MODEL started Raya over Dubravka at GW2 despite projecting 3.41 against 3.68 this week — about 0.27 points given up to protect a lineup choice it would make again in seven days. There is never a reason to do that.

MODEL forgoes chips entirely. No wildcard, no bench boost, no triple captain. That is a real disadvantage against any human who uses them, and it is stated rather than hidden — a known handicap is honest, an unstated advantage is not.

From GW3 the two tracks are not playing the same game every week. Triple Captain was played on the human track at GW3; MODEL and MODEL-DIFF are forbidden from holding a chip at all. That is an asymmetry in the rules of that week, not in judgement, and the record keeps the two apart: chip_divergence is its own field and is deliberately excluded from matches_model. Folding it in would let a chip masquerade as a selection override — attributing to squad-picking a difference that has nothing to do with it.

The season comparison is therefore reported both ways, including chip weeks and excluding them, and so is the override figure. Both numbers are true. Showing only the inclusive one would flatter the human track for a reason that is not skill; showing only the exclusive one would hide points that were really scored. The same discipline as the coverage fix: a total is only a comparison if the things being compared were playing under the same rules.

The captain multiplier follows from this and is no longer a constant anywhere. The projection derives it — 3 under Triple Captain, 2 otherwise — and scoring does not derive it at all: it reads the multiplier the game actually applied off the picks endpoint. That is authoritative, and it means a chip declared but never played, or played but never declared, cannot silently mis-score a week. GW1 and GW2 rescored under the new code with every score unchanged, taking their ×2 from the API rather than from the old constant.

THE PAGE shows your chips, which is a different thing: what you still hold and whether it is playable this gameweek, with every window and count read from the game rules rather than assumed. Those rules have changed between seasons, and a hardcoded window does not fail loudly — it quietly reports a chip as available long after it expired. It offers no timing advice, and that is deliberate. When to play a Wildcard or a Free Hit is decided almost entirely by blank and double gameweeks, and this early in the season those do not exist yet: they are created later by cup progression and postponements. A recommendation made now would be invented, and inventing one on a page whose whole argument is its accuracy record is a bad trade at any price. What can be said honestly is a fixture run — which of the next six gameweeks is hardest for the fifteen you own, from the same fixture multipliers as every other number here. That is the useful half of a chip planner, and it is labelled as a fixture run rather than dressed up as advice.

As of v0.6 that is enforced rather than promised. FPL gives every manager two wildcards, one playable up to the GW20 deadline and one from GW20 on; model_state.json now carries both, and MODEL holds both unplayed. The check is arithmetic rather than a flag, because an unlimited-transfer week is a wildcard whatever it is called: every gameweek, every transfer beyond the free ones must have been paid for at the full −4. A squad that arrives without a priced move gets recorded as a wildcard rather than waved through. Before v0.4 that check would have failed every single week, which is exactly the bug it exists to stop coming back.

GW2 was nearly a dirty week, and is not one. It was first frozen on 22 August under the old unconstrained solve — all fifteen rebuilt from scratch, which is a free wildcard by another name. Under the v0.6 freeze rule that freeze was still provisional, because the GW2 deadline had not passed, so it was superseded on 26 August by a legal one-transfer move from the GW1 squad. MODEL never fielded the rebuild and never banked anything from it, so there is no advantage to legalise and no chip is spent. The superseded version is not deleted: it is summarised inside predictions/GW2.json under superseded, with the timestamp at which it stopped being the prediction, and it remains in git as its own commit.

The differential dial

Maximising expected points converges on the template, because every optimiser solves the same problem against the same public data. A template team scores approximately the average. You can execute it flawlessly for 38 gameweeks and finish comfortably mid-table.

Raising this dial lowers your expected score and raises your chance of a high finish. Owning what everyone owns guarantees you the average. It cannot beat it.

The mechanism is a proportional haircut on players the field already holds: effective_xP = xP − w × (ownership ÷ 100) × xP. At w = 0, the default, it is the identity and nothing changes. It is a selection criterion only — every projection this site reports is still true expected points. The model has not changed its mind about what a player will score, only about what it is worth owning him.

Measured on the current pool, the dial does nothing at all until roughly 0.33: the expected-points gap on the template picks is wider than a 30% ownership haircut, so the same squad survives. At 0.35 six players change and mean squad ownership falls from about 18% to 15%, while true XI xP falls from 51.8 to 49.6. That is the trade being made, in numbers.

MODEL-DIFF is a third track running the identical transfer rules at w = 0.35, anchored on the same GW1 squad. Over a season the MODEL vs MODEL-DIFF comparison answers whether differential exposure actually pays at this model's accuracy — a genuinely open question, and a better answer than any single gameweek.

This uses raw selected_by_percent. True effective ownership weights by captaincy within a rank cohort, which needs data this build does not have. The proxy is the honest approximation and is named as one.

The accuracy record — four tracks

Before every deadline a prediction is written to predictions/GW{n}.json and committed, so its timestamp is verifiable in git history rather than asserted here.

A prediction is provisional until its deadline and immutable after it. Before the deadline it is re-frozen on every hourly run, so the committed prediction is always the last state before the cutoff — current prices, current team news, the transfer decision made when it is actually due. Once the deadline passes it is never rewritten: not by a later run, not to correct it, and there is no --force flag, because a switch that can bypass the guarantee makes the guarantee unverifiable to anyone reading the repo.

v0.4 got this wrong in the safe-looking direction: it refused to overwrite any existing prediction, which froze GW2 six days early against stale prices. Every intermediate freeze is its own git commit and every superseded version is kept inside the file, so nothing is lost to honesty by re-freezing before the deadline. Scoring a finished gameweek is not editing a prediction either — actuals are a separate record appended after the fact.

Three tracks are scored against each other:

HUMAN is measured twice, because there are two different quantities. frozen_xi_actual is the frozen eleven scored with the frozen captain, autosubs ignored — that is the like-for-like number against MODEL, which is a paper portfolio and gets no autosubs either. entry_actual is what the entry actually banked on the official site, autosubs and hits included. When the two diverge, autosubs did real work and the bench ordering earned something, which is worth knowing on its own rather than folded into one total. In GW1 they agree at 36: every starter played, so nothing was subbed on.

The question this answers, which a season-long total cannot: in the weeks the model was overridden, did the override gain or lose points? Weeks where the two tracks are identical are copies — they contribute nothing to that figure by construction, and excluding them is exactly what makes it mean something. Including the bad weeks. A tool that sells its model can't publish this; there is no revenue here to protect, which is the only reason it can be a real position rather than a gimmick.

Which model made which row

The accuracy record compares projections with actuals across gameweeks, and until v0.10 it had no idea which model made any of them. That was survivable while the model barely moved. v0.10 changes the projection substantially — expected minutes, blended rates, underlying inputs — so from GW5 the record holds rows from two different models, and a record that cannot tell a model change from a model improvement will read a step change as a trend.

Predictions frozen from v0.10 onward stamp their own version. Earlier ones cannot: past its deadline a prediction is never edited, and that rule has no exceptions — it is the only thing making this record worth anything. So GW1–GW4 carry a version derived from git: every release in this repo lands as a commit whose subject begins with its version, so the version in force at a freeze is the newest such commit at or before it. That is a fact about the repository rather than somebody's recollection. data/model_versions.json holds the derived timeline and is committed, because CI clones at depth 1 and cannot derive it.

One honest limitation. This is the repo release in force, not a model-only version number. v0.5 changed no model and neither did v0.9.1, so two rows carrying different versions were not necessarily produced by different models. It is an upper bound on change, which is the right direction for a record to err in.

GW2 is the sharp case. It was frozen at 16:40:47Z and v0.8's first commit landed at 16:40:58Z — eleven seconds later — so the row reads v0.7, correctly.

The benchmarks, and what they are for

Three numbers sit beside your own, and they answer different questions.

The average manager is the only one that is a fair target. average_entry_score is read off the event record after the fact; there is no honest projection of it before a gameweek is played, so it appears only on finished weeks.

The week's best score — highest_score on the same event object — is the distance to the top of one gameweek, not to the top of the table. It moves enormously week to week and belongs to a different manager almost every time.

The overall leader is taken from page one of the global Overall classic league, id 314, observed once per gameweek because it is a heavy response for a single number. It is not a target. The manager at the top of nine million entries is there on a combination of skill and a tail of luck that does not repeat to order, and the gap is not closeable by playing well — it is there so the scale of the season is visible, and so a good week can be seen for what it is against the best week anyone had. Presenting it as something to chase would be the same mistake as drawing a trend line through three gameweeks.

The leader's path is one manager's season, not a record of who led each week. This is the most important thing on this page to read correctly, so it is said twice. The chart's fifth line is built by taking whoever tops the table today and fetching their full gameweek history — which means it is survivorship-biased by construction. The manager currently first was 41st after GW1; the manager who actually led after GW1 is not on the chart at all, because they are no longer top and nobody records who was. A line labelled "the leader each week" would be false, and this one is labelled the current leader's path and drawn dashed to mark it as a reference rather than a track being measured.

It is also not like-for-like with MODEL. That entry's total already includes a Bench Boost and a Triple Captain; MODEL forgoes chips entirely by declaration. Two of the first two gameweeks were chip weeks, and a total built partly on chips is not the same quantity as one built without them.

The entry id is re-read from the standings every gameweek rather than pinned, because the top of nine million entries changes — pinning it would quietly turn this into "some manager who once led". When the leader changes, the whole line changes with them, which is a property of the question rather than a bug.

The gap direction is computed against that same manager's path, not between two snapshots of whoever was leading at the time. That is a narrower claim and a true one: the gap to this manager grew or shrank by this much. A difference taken between two different leaders would be a difference of two quantities that are not the same quantity — which is also why total − event_total is never used to invent a previous value: that is that entry's total last week, not the leader's.

Corrections

Errors found in the published record after the fact. A frozen prediction is never edited to hide one — past its deadline it is immutable, and a record that quietly fixes its own mistakes is worth less than one that shows them. So the mistake stays in the file and the correction is logged beside it, with what it did and did not affect.

Where the error sits

One MAE hides everything worth knowing. A gameweek that misses by twenty points reads as uniformly mediocre, and it never is. So for every player in the frozen MODEL XI this records projected against actual split by the same four components the projection is built from — appearance, attack, defence, bonus — and totals them by component, by position, and by how much Premier League evidence sits behind the player.

The projected side is never recomputed from today's data. Where the frozen prediction carries its own component split it is read straight out; for gameweeks frozen before it did, the exact data/projections.json snapshot behind the freeze is recovered from git and checked against the xP that was actually published before it is used. A prediction is only evidence if it is read as it was written.

The formula carries a fifth term, deductions. Each deduction is folded into the component it offsets — goals conceded into defence, cards into appearance — on both the projected and the actual side, so the four parts sum to the total and a component error is a real quantity rather than a leftover. Sign convention: error = actual − projected, so a negative number means the model expected more than happened.

The build-time dials

Six of v0.10's settings are consumed before data/projections.json is written — the minutes window and its decay produce p_start, the blend K produces every per-90 rate, the two discounts produce the availability gate. By the time this page has the file, their work is baked in.

So they are not sliders. Every dial in the panel below is live, and a slider that cannot change the number next to it is a lie the page tells about itself. These are listed here instead, with their committed values read live out of the file. Making them live is possible — it needs each player's per-gameweek minutes published so the browser can re-run the minutes model — and that is a feature rather than a fix. It is not in this version.

None of these defaults has been moved. Every one ships at the value the work order specified. The backtest is what has standing to change one, and doing so is a separate commit with its own justification.

Every dial, in full

THE PAGE keeps these behind a lid, closed, with one line of description each — eight sliders and an essay apiece, permanently open beside the team, was the most intimidating thing on the page and it made a tool that answers questions look like a tool that asks them. The essays live here, where a paragraph belongs. Values are the shipped defaults; anything you change is stored in your browser and nowhere else.


Built in a day against the public FPL API. Static: a scheduled job fetches, commits a JSON snapshot, and Netlify serves it. No backend, no database, no API key, no account of yours touched.