<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Roman Koropets</title>
    <description>The latest articles on DEV Community by Roman Koropets (@romankoropets).</description>
    <link>https://dev.to/romankoropets</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4086566%2F525971bf-a304-4092-bff8-f8798ad5ef35.jpg</url>
      <title>DEV Community: Roman Koropets</title>
      <link>https://dev.to/romankoropets</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/romankoropets"/>
    <language>en</language>
    <item>
      <title>Computing WHO growth percentiles on-device</title>
      <dc:creator>Roman Koropets</dc:creator>
      <pubDate>Thu, 20 Aug 2026 13:04:15 +0000</pubDate>
      <link>https://dev.to/romankoropets/computing-who-growth-percentiles-on-device-4b5n</link>
      <guid>https://dev.to/romankoropets/computing-who-growth-percentiles-on-device-4b5n</guid>
      <description>&lt;p&gt;Every baby tracker shows growth percentiles. "Your daughter is in the 72nd percentile for weight." It looks like a lookup — find the row for her age, compare, print a number.&lt;/p&gt;

&lt;p&gt;It isn't. And the ways it goes wrong are interesting enough to be worth writing down.&lt;/p&gt;

&lt;p&gt;I ended up implementing this properly for a baby journal app, and published the result as &lt;a href="https://github.com/SunnySeedApp/who-growth-standards" rel="noopener noreferrer"&gt;&lt;code&gt;who-growth-standards&lt;/code&gt;&lt;/a&gt; — MIT, zero dependencies, all the WHO tables bundled. This post is the reasoning behind it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with "just look it up"
&lt;/h2&gt;

&lt;p&gt;The WHO Child Growth Standards don't publish percentiles directly. They publish three numbers per age per sex: &lt;strong&gt;L&lt;/strong&gt;, &lt;strong&gt;M&lt;/strong&gt; and &lt;strong&gt;S&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;M&lt;/strong&gt; is the median — the 50th percentile value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;S&lt;/strong&gt; is the coefficient of variation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;L&lt;/strong&gt; is a Box-Cox power that handles skew.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one exists because growth data isn't normally distributed. At three months a boy's median weight is 6.37 kg, and the band around it isn't symmetric: −2 SD sits 1.36 kg below the median, +2 SD sits 1.65 kg above it. The tail stretches further up than down, and L is what rescales it so a z-score means the same thing on both sides.&lt;/p&gt;

&lt;p&gt;The z-score comes out as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;z = ((X / M)^L − 1) / (L × S)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the percentile is just the normal CDF of that z-score. Two lines of code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;lmsToZScore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ship it, right?&lt;/p&gt;

&lt;h2&gt;
  
  
  The L = 0 problem
&lt;/h2&gt;

&lt;p&gt;Look at the formula again. When &lt;code&gt;L&lt;/code&gt; is zero, you divide by zero.&lt;/p&gt;

&lt;p&gt;This isn't hypothetical, though you have to look closely to see it. L drifts with age in only two of the six indicators — and those two are exactly the ones that cross zero. BMI-for-age crosses in the first days of life, going from −0.0681 on day 2 to +0.0505 on day 3, then crosses back around day 93. Weight-for-age for boys reaches L = 0.0001 on day 654 and −0.0001 on day 655.&lt;/p&gt;

&lt;p&gt;The other four never come close: length/height-for-age and head-circumference-for-age publish L = 1 at every single row, and the weight-for-length pair holds a constant −0.35.&lt;/p&gt;

&lt;p&gt;The Box-Cox transform has a defined limit there, and it's the logarithm:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;lmsToZScore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No published row has L exactly zero, so you might think the branch is decorative. It isn't: interpolate weight-for-age at 654.5 days — a boy a few days short of twenty-two months — and L lands on exactly 0. Without the branch that's a &lt;code&gt;NaN&lt;/code&gt;, and it fails silently, because nobody validates a percentile that came back as &lt;code&gt;NaN&lt;/code&gt; until a parent screenshots it.&lt;/p&gt;

&lt;p&gt;There's a related trap I hit while writing tests. My first test asserted continuity: that as L approaches zero, the power form converges to the logarithmic one. It failed at &lt;code&gt;L = 1e-12&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That's not a bug in the maths — it's catastrophic cancellation. &lt;code&gt;(X/M)^L&lt;/code&gt; for tiny L is &lt;code&gt;1 + L·ln(X/M) + …&lt;/code&gt;, a number extremely close to 1. Subtracting 1 destroys most of the significant digits, and the smaller L gets, the worse the result. Agreement is best around &lt;code&gt;1e-7&lt;/code&gt; and degrades sharply below &lt;code&gt;1e-8&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The test now says so explicitly, because the naive version of that assertion looks correct and fails for reasons that take an hour to understand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;approaches the logarithmic form as L → 0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Cannot be pushed arbitrarily close to zero: (X/M)^L − 1 loses significant&lt;/span&gt;
  &lt;span class="c1"&gt;// digits catastrophically for tiny L, so agreement gets *worse* below ~1e-8.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;atZero&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;lmsToZScore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.12&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;lmsToZScore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.12&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBeCloseTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;atZero&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Getting the actual tables
&lt;/h2&gt;

&lt;p&gt;The WHO publishes these as Excel files, one per indicator per sex, at &lt;code&gt;cdn.who.int&lt;/code&gt;. Six indicators × two sexes = twelve files, roughly 17 000 rows of LMS triples in total.&lt;/p&gt;

&lt;p&gt;My own app takes the lazy route: fourteen anchor points per table — months 0, 1, 2, 3, 4, 5, 6, 9, 12, 18, 24, 36, 48, 60 — with straight lines between them. It draws a perfectly convincing chart and it is quietly off by a percentile or two in the middle of each gap. Invisible in testing. Bundling the real table instead costs half a megabyte and removes the question entirely.&lt;/p&gt;

&lt;p&gt;The other half of doing it properly is to &lt;strong&gt;generate&lt;/strong&gt; rather than hand-copy. My repo has a script that downloads the source files and emits typed TypeScript modules:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;SOURCES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wfa&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;weight-for-age/expanded-tables/wfa-{sex}-zscore-expanded-tables.xlsx&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;day&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Weight-for-age&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lhfa&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;length-height-for-age/expandable-tables/lhfa-{sex}-zscore-expanded-tables.xlsx&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
             &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;day&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Length/height-for-age&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="c1"&gt;# …
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two practical notes. First, the URL patterns are inconsistent — some indicators live under &lt;code&gt;expanded-tables&lt;/code&gt;, one under &lt;code&gt;expandable-tables&lt;/code&gt;, and one file is &lt;code&gt;-table.xlsx&lt;/code&gt; while its sibling is &lt;code&gt;-tables.xlsx&lt;/code&gt;. Finding them took longer than parsing them.&lt;/p&gt;

&lt;p&gt;Second: generated data files should be reproducible. Re-running my generator produces byte-identical output, which means the tables in the repo are verifiably the WHO's numbers and not something that drifted through a manual edit three commits ago.&lt;/p&gt;

&lt;p&gt;Age-based indicators come at &lt;strong&gt;daily&lt;/strong&gt; resolution — 1857 rows covering 0 to 1856 days, the full 0–5 years. Weight-for-length and weight-for-height are indexed by centimetres in 0.1 cm steps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Interpolation, and why it still matters
&lt;/h2&gt;

&lt;p&gt;With daily tables you might think interpolation is unnecessary. It isn't — real applications pass fractional values.&lt;/p&gt;

&lt;p&gt;A measurement taken at 100.5 days. A length of 74.35 cm. Whether you floor, round, or interpolate changes the answer, and the difference is largest exactly where the curve is steepest — the first weeks of life, which is when parents check most obsessively.&lt;/p&gt;

&lt;p&gt;Linear interpolation between adjacent grid points is enough here, because the grid is dense relative to how fast L, M and S change. But it should be a deliberate choice rather than an accident of &lt;code&gt;Math.floor&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that actually matters: preterm infants
&lt;/h2&gt;

&lt;p&gt;Here's the case that convinced me this deserved to be a library rather than a file in one app.&lt;/p&gt;

&lt;p&gt;The WHO standards describe children &lt;strong&gt;born at term&lt;/strong&gt;. Apply them directly to a baby born at 32 weeks, and every comparison is against children who had eight extra weeks to grow.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chronological&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                                &lt;span class="c1"&gt;// days since birth&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;corrected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;correctedAgeInDays&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chronological&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// → 64&lt;/span&gt;

&lt;span class="nf"&gt;weightForAge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;5.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;sex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;male&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ageDays&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;chronological&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nx"&gt;zScore&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// −2.53&lt;/span&gt;
&lt;span class="nf"&gt;weightForAge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;5.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;sex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;male&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ageDays&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;corrected&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nx"&gt;zScore&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// −0.69&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same baby. Same weight. Same day.&lt;/p&gt;

&lt;p&gt;Uncorrected, that's &lt;code&gt;−2.53&lt;/code&gt; — below the WHO cut-off, the range where a clinician starts investigating. Corrected, it's &lt;code&gt;−0.69&lt;/code&gt; — unremarkable, middle of the normal band.&lt;/p&gt;

&lt;p&gt;If your app skips this, you are showing parents of premature babies a red flag that shouldn't be there. Given that these parents have usually just spent weeks in a NICU, that's not a rounding error, it's a cruelty.&lt;/p&gt;

&lt;p&gt;The correction itself is trivial arithmetic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;correctedAgeInDays&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ageDays&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;gestationalAgeWeeks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gestationalAgeWeeks&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;37&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;ageDays&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// 37+ weeks is term&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ageDays&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;gestationalAgeWeeks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What isn't trivial is knowing it's needed, and knowing when to stop — correction is conventionally applied until 2 years, or 3 for extreme prematurity. That's a clinical judgement, so the library computes the corrected age and leaves the cut-off to the caller.&lt;/p&gt;

&lt;p&gt;One more thing worth stating, because it's a common mix-up: correction applies to &lt;strong&gt;growth and development&lt;/strong&gt;, never to vaccination schedules. Those follow chronological age.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why compute this locally
&lt;/h2&gt;

&lt;p&gt;Two reasons, one obvious and one less so.&lt;/p&gt;

&lt;p&gt;The first is proportionality. The input is a child's weight, height and date of birth, and a round-trip to a server to divide two numbers is a strange trade for that. To be precise about it: the app this came from does sync measurements to its own backend, because parents change phones and two parents share one child — but nothing has to leave the device to answer &lt;em&gt;where does she sit on the curve&lt;/em&gt;, and no third party is involved in that answer.&lt;/p&gt;

&lt;p&gt;The less obvious one is that &lt;strong&gt;the network is the least reliable part of the stack&lt;/strong&gt;, and parents log measurements in exactly the places where it fails: a paediatrician's basement office, a hospital corridor, home at 3am with the wifi router two floors down. A percentile that needs a round-trip is a percentile that sometimes isn't there.&lt;/p&gt;

&lt;p&gt;The whole dataset is about 500 KB unminified — the size of a couple of photos. There's no technical reason to put it behind an API.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the library doesn't do
&lt;/h2&gt;

&lt;p&gt;It computes numbers. It does not interpret them.&lt;/p&gt;

&lt;p&gt;There's a &lt;code&gt;classify()&lt;/code&gt; helper that reports where a z-score falls against WHO cut-offs, and it's tempting to read that as a verdict. It isn't. Those cut-offs come from children raised in conditions WHO defines as favourable — breastfed, non-smoking households, adequate nutrition. They describe how growth tends to look under good conditions, not a rule a particular child must obey. A child at the 3rd percentile can be perfectly healthy and simply small; a child at the 50th can have something going on. That judgement belongs to someone who has met the child.&lt;/p&gt;

&lt;p&gt;Other limits worth knowing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;0–5 years only.&lt;/strong&gt; The WHO 2007 reference covers 5–19 and is a different dataset — not included yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Length and height aren't interchangeable.&lt;/strong&gt; Under 2 years children are measured lying down, from 2 years standing, and the difference is roughly 0.7 cm. That's why weight-for-length and weight-for-height are separate tables rather than one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Units are taken on faith.&lt;/strong&gt; Pass pounds where kilograms are expected and you'll get a confident, wrong answer.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Using it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;who-growth-standards
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;weightForAge&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ageInDays&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;classify&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;who-growth-standards&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;age&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ageInDays&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2025-11-14&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;  &lt;span class="c1"&gt;// days since that birthday&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;weightForAge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;8.9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;sex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;female&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ageDays&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;279&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;zScore&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;            &lt;span class="c1"&gt;// 0.5993&lt;/span&gt;
&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;percentile&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// 72.55&lt;/span&gt;
&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;median&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;            &lt;span class="c1"&gt;// 8.27 kg at this age&lt;/span&gt;
&lt;span class="nf"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;zScore&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// "normal"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Six indicators, both sexes, out-of-range input throws by default with opt-in clamping, full TypeScript types, no runtime dependencies.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/SunnySeedApp/who-growth-standards" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · &lt;a href="https://www.npmjs.com/package/who-growth-standards" rel="noopener noreferrer"&gt;npm&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It came out of building &lt;a href="https://sunnyseed.app" rel="noopener noreferrer"&gt;Sunny Seed&lt;/a&gt;, a baby journal that computes these percentiles on the device. Writing the same maths a second time — properly, with the full tables, in the open — seemed more useful than leaving a fourteen-point approximation buried in an app. The formula is the same for everyone, and the failure modes above are worth not rediscovering one at a time.&lt;/p&gt;

&lt;p&gt;If you spot something wrong in it, issues and PRs are welcome. Especially if you know the WHO 2007 reference well enough to add it.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>healthcare</category>
      <category>opensource</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
