<?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>Forem: Artur Goncharov</title>
    <description>The latest articles on Forem by Artur Goncharov (@goncharovart).</description>
    <link>https://forem.com/goncharovart</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3887912%2Ffd839fc8-87d3-412b-b675-9ef2d943b12b.png</url>
      <title>Forem: Artur Goncharov</title>
      <link>https://forem.com/goncharovart</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/goncharovart"/>
    <language>en</language>
    <item>
      <title>Two months, one engineer, one production B2B marketplace — what AI-directed full-stack actually looks like</title>
      <dc:creator>Artur Goncharov</dc:creator>
      <pubDate>Mon, 20 Apr 2026 21:02:37 +0000</pubDate>
      <link>https://forem.com/goncharovart/two-months-one-engineer-one-production-b2b-marketplace-what-ai-directed-full-stack-actually-11hn</link>
      <guid>https://forem.com/goncharovart/two-months-one-engineer-one-production-b2b-marketplace-what-ai-directed-full-stack-actually-11hn</guid>
      <description>&lt;p&gt;I shipped a production B2B marketplace for the Russian HVAC industry — &lt;a href="https://wentmarket.ru" rel="noopener noreferrer"&gt;wentmarket.ru&lt;/a&gt; — alone, in two months, end to end. Not an MVP. Full commerce, seven external integrations, seventeen engineering calculation engines, 101 Prisma models, 446 test files, real paying customers.&lt;/p&gt;

&lt;p&gt;This post is the breakdown of what was actually in it, how the work was split between me and Claude Code, and the parts I would not delegate again.&lt;/p&gt;

&lt;p&gt;If your first reaction is "solo-built in two months is a red flag, not a feature" — good, mine would be too. Stick around. The answer is disclosure plus a list of concrete things that are in the repo.&lt;/p&gt;




&lt;h2&gt;
  
  
  What "AI-directed engineering" actually means here
&lt;/h2&gt;

&lt;p&gt;The division of labor across the two months:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Me:&lt;/strong&gt; product scope, domain model, database schema, integration architecture, performance budget, edge cases, production QA, every decision that touches revenue or data integrity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Claude Code:&lt;/strong&gt; CRUD handlers, first-pass tests, boilerplate React components, email templates, refactors I specify line by line, anything with an obvious shape.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is not autocomplete. It is a workflow: I write the spec, Claude writes the first draft, I review and rewrite maybe 30–50% of it, Claude applies the rewrite, I ship. The failure mode is not "AI writes broken code" (current models rarely do on well-scoped tasks), the failure mode is "AI writes working code for the wrong problem." That is a human problem and it does not go away with better models.&lt;/p&gt;

&lt;p&gt;In exchange for the discipline of writing clear specs, I get maybe a 3× throughput improvement on everything that is not an architecture decision. On a two-month solo sprint that compounds into roughly a team-of-four's worth of output — which is the number I actually felt.&lt;/p&gt;




&lt;h2&gt;
  
  
  The product, itemized
&lt;/h2&gt;

&lt;p&gt;wentmarket is a two-sided marketplace for HVAC (ventilation equipment). B2B procurement, B2C retail, and a contractor-oriented engineering layer that helps specify the right fan / air handling unit / silencer for a duty point.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Commerce&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;584 products, 2,308 configurable product variants, 2,139 motor options, 62 categories&lt;/li&gt;
&lt;li&gt;B2B portal with client-specific pricing pulled from 1C ERP, quote workflow, PDF submittal generation&lt;/li&gt;
&lt;li&gt;B2C checkout with CDEK delivery rates, Yookassa payment with 54-FZ fiscal receipts and idempotent webhooks&lt;/li&gt;
&lt;li&gt;35-section admin panel for moderation, inventory, orders, manual overrides&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Engineering&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;17 calculation engines total, including:

&lt;ul&gt;
&lt;li&gt;Fan selection by duty point (flow + pressure) across 18,141 polynomial pressure–flow curves indexed in Postgres, 4 ms average match time&lt;/li&gt;
&lt;li&gt;Air-handling-unit configurator with 10 sub-engines (psychrometric, ε-NTU heat-exchanger sizing, octave-band acoustics, filter selection, recuperator, fan-block, silencer, damper, control, life-cycle cost)&lt;/li&gt;
&lt;li&gt;Silencer selector (acoustic sum across 6 components)&lt;/li&gt;
&lt;li&gt;Life-cycle-cost calculator (15-year horizon, energy + maintenance)&lt;/li&gt;
&lt;li&gt;VFD (variable-frequency drive) sizing&lt;/li&gt;
&lt;li&gt;Duct pressure-loss calculator&lt;/li&gt;
&lt;li&gt;BIM model generator for engineering deliverables&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;385 automated engineering tests against manufacturer reference data; CI blocks merges at &amp;gt;0.5% deviation&lt;/li&gt;

&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Integrations (each with retry, timeout, HMAC/signature verification, graceful degradation)&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;1C ERP — bidirectional product and order sync over Russian SOAP&lt;/li&gt;
&lt;li&gt;Bitrix24 CRM — full service layer, ~1,500 LOC, lead + deal + customer + funnel analytics&lt;/li&gt;
&lt;li&gt;Yookassa — payment processing, 54-FZ fiscal receipts, SHA-256 idempotency&lt;/li&gt;
&lt;li&gt;CDEK — delivery cost calculation, order creation, courier dispatch, PDF waybill&lt;/li&gt;
&lt;li&gt;Meilisearch — typo-tolerant search with Russian synonyms, Prisma fallback if Meilisearch is down&lt;/li&gt;
&lt;li&gt;Telegram — bot with 40+ commands + Mini App with HMAC-verified initData auth&lt;/li&gt;
&lt;li&gt;DaData — company lookup by INN, Sentry for error monitoring&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Stack&lt;/strong&gt;&lt;br&gt;
Next.js 16 (App Router, Turbopack), React 19, TypeScript strict, Prisma ORM, PostgreSQL, Redis, BullMQ, NextAuth v5, Docker, GitHub Actions, Vitest (unit + integration), Playwright (E2E across desktop + mobile viewports). Linux VPS, systemd service, Nginx reverse proxy with 50 r/s rate limiting. Redis L2 cache, BullMQ job queues, distributed rate limiting, PostgreSQL soft-delete via Prisma &lt;code&gt;$extends&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Testing&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;446 test files total&lt;/li&gt;
&lt;li&gt;1,574 unit tests (Vitest, 60%+ coverage)&lt;/li&gt;
&lt;li&gt;165 Playwright E2E tests across 17 user-journey spec files&lt;/li&gt;
&lt;li&gt;385 engineering tests, manufacturer-reference-data parity&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is not decoration. The 385 engineering tests exist because fan selection is a safety-adjacent decision — a mis-sized fan in a kitchen ventilation system is an actual fire risk.&lt;/p&gt;




&lt;h2&gt;
  
  
  The parts that took longer than they should have, honestly
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;1C ERP integration.&lt;/strong&gt; Russian accounting software speaks a SOAP dialect that is half ISO-20022 and half vibes. The schema is publicly undocumented in English. Took about two weeks including a very educational detour into Microsoft Windows character encoding on the wire. If you have not touched &lt;code&gt;windows-1251&lt;/code&gt; in a while, it is still there, waiting.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Polynomial curve ingestion.&lt;/strong&gt; 18,141 curves came from manufacturer PDFs, scraped HTML tables, Delphi data files, and Excel sheets with merged cells. Each format needed its own parser and a verification harness that plotted the result against the original datasheet to catch fit drift. Curves are stored as &lt;code&gt;double precision[]&lt;/code&gt; coefficient arrays in Postgres; Horner's-method evaluation evaluates each at ~30 ns. Wrote that up separately &lt;a href="https://dev.to/goncharovart/how-i-indexed-18141-polynomial-fan-curves-in-postgres-and-matched-a-duty-point-in-4-ms-ljp"&gt;here&lt;/a&gt; with the OSS extract at &lt;a href="https://github.com/goncharovart/polynomial-fan-matcher" rel="noopener noreferrer"&gt;polynomial-fan-matcher&lt;/a&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The AHU configurator UX.&lt;/strong&gt; First version was a drag-and-drop canvas. Showed it to three HVAC engineers, they bounced off it in 30 seconds. Rebuilt around what they actually do: enter a duty point, receive three matching AHUs, override individual components in focused forms. Time-to-first-viable-configuration went from ~12 min to ~40 sec on the same users. Killed six weeks of work making that call. I would do it again.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Testing the engineering engines.&lt;/strong&gt; This is where the "Claude does tests" claim has to be qualified. Claude writes test scaffolding and first-pass assertions — but the actual reference data (expected outputs) comes from manufacturer datasheets that I had to digitize by hand, and the tolerance bands come from ASHRAE / СП 60.13330. That calibration is irreducibly human, irreducibly domain-specific, and it is where the value is.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  What I would not delegate to Claude, even next time
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Domain model decisions.&lt;/strong&gt; A &lt;code&gt;Product&lt;/code&gt; vs. a &lt;code&gt;ProductSeries&lt;/code&gt; vs. a &lt;code&gt;ProductVariant&lt;/code&gt; vs. a &lt;code&gt;MotorOption&lt;/code&gt; is not a naming problem, it is a commercial model. Getting that wrong costs you the next six months.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Integration contracts.&lt;/strong&gt; What exact payload do we send to 1C when a B2B client places a quote that has not yet been approved? The answer depends on the customer's internal workflow, not on the 1C documentation. A model cannot solve that for you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Error-handling philosophy.&lt;/strong&gt; Fail fast or degrade gracefully? The answer is different for the payment webhook (fail fast, write to the incident table, the human decides) and for the search index (degrade gracefully, fall back to Prisma &lt;code&gt;LIKE&lt;/code&gt;, never block commerce on search availability). Picking the right mode per surface is an architecture call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What not to build.&lt;/strong&gt; Every feature I did not build saved me more time than the ones I did build. "Should we add a chatbot" / "should we build a mobile app" / "should we add multi-currency pricing" all got answered "not in V1" and that is why V1 shipped.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Numbers that matter, briefly
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;584 products, 2,308 variants, 2,139 motor options, 62 categories&lt;/li&gt;
&lt;li&gt;101 Prisma models, 40+ enums, 35-section admin panel&lt;/li&gt;
&lt;li&gt;18,141 polynomial curves, 4 ms duty-point match, 385 engineering tests&lt;/li&gt;
&lt;li&gt;446 test files, 60%+ unit coverage, 165 E2E specs across desktop + mobile&lt;/li&gt;
&lt;li&gt;7 production integrations, each with retry / timeout / HMAC / graceful fallback&lt;/li&gt;
&lt;li&gt;Two months, one engineer, with Claude Code as a build-accelerator&lt;/li&gt;
&lt;li&gt;Live, paying customers&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Why I am posting this
&lt;/h2&gt;

&lt;p&gt;Two honest reasons.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One:&lt;/strong&gt; I think the industry is still calibrating what "AI-accelerated solo engineering" actually looks like on a serious product. A lot of the public discourse is either "solo founders ship magical 10× speed with AI now" or "it is a glorified autocomplete, nothing has changed." Neither is useful. The real thing is closer to: with the right discipline, one competent senior engineer can ship what a team of four used to ship, on a real B2B product with real integrations, in a short window. I wanted to describe that specifically rather than generically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two:&lt;/strong&gt; I am looking for offers. Open to engineering roles (remote or relocation), licensing the engineering engines to a Western HVAC or building-simulation vendor, partnership conversations, or acquisition discussions. I am in Russia (UTC+3), fluent in written English, speaking still developing so I work async-first.&lt;/p&gt;

&lt;p&gt;If any of that is interesting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Live product: &lt;a href="https://wentmarket.ru" rel="noopener noreferrer"&gt;wentmarket.ru&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;OSS extract (fan-matching polynomial engine): &lt;a href="https://github.com/goncharovart/polynomial-fan-matcher" rel="noopener noreferrer"&gt;polynomial-fan-matcher&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;5-min demo video: &lt;a href="https://youtu.be/sZnvwEfCwVk" rel="noopener noreferrer"&gt;youtu.be/sZnvwEfCwVk&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Contact: &lt;strong&gt;&lt;a href="mailto:goncharov.artur.02@gmail.com"&gt;goncharov.artur.02@gmail.com&lt;/a&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Happy to go deeper on any specific engine, integration, or architectural decision in the comments.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>showdev</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How I indexed 18,141 polynomial fan curves in Postgres and matched a duty point in 4 ms</title>
      <dc:creator>Artur Goncharov</dc:creator>
      <pubDate>Mon, 20 Apr 2026 08:24:19 +0000</pubDate>
      <link>https://forem.com/goncharovart/how-i-indexed-18141-polynomial-fan-curves-in-postgres-and-matched-a-duty-point-in-4-ms-5aim</link>
      <guid>https://forem.com/goncharovart/how-i-indexed-18141-polynomial-fan-curves-in-postgres-and-matched-a-duty-point-in-4-ms-5aim</guid>
      <description>&lt;p&gt;If you work in building HVAC, you know the ritual. You need a fan that will push 5000 m³/h at 400 Pa. Your manufacturer rep emails you a 400 MB installer — usually Windows-only, often Delphi, almost always from 2007 — and you click through dialog boxes until the program picks a fan for you. The selection engine is a black box. You cannot embed it. You cannot query it. You cannot even link to a result.&lt;/p&gt;

&lt;p&gt;The math underneath is not complicated. It has been public for decades. It is just locked behind a desktop binary.&lt;/p&gt;

&lt;p&gt;I have been building a web-native alternative for a Russian HVAC marketplace. The selection engine now indexes &lt;strong&gt;18,141 real manufacturer pressure–flow curves&lt;/strong&gt; across 13 fan families, and picks a matching fan for a given duty point in about 4 ms on a single Postgres row read plus in-memory evaluation. This post walks through the storage model, the evaluation math, the matching loop, and the three gotchas that each cost me roughly a week.&lt;/p&gt;

&lt;p&gt;Full working code: &lt;a href="https://github.com/goncharovart/polynomial-fan-matcher" rel="noopener noreferrer"&gt;github.com/goncharovart/polynomial-fan-matcher&lt;/a&gt;. Everything below runs in production on &lt;a href="https://wentmarket.ru" rel="noopener noreferrer"&gt;wentmarket.ru&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem, stated precisely
&lt;/h2&gt;

&lt;p&gt;A fan has a characteristic curve: for every volumetric flow rate &lt;code&gt;Q&lt;/code&gt; (in m³/h) the fan is capable of producing, there is a corresponding static pressure &lt;code&gt;P(Q)&lt;/code&gt; (in Pa) it can develop. As &lt;code&gt;Q&lt;/code&gt; goes up, &lt;code&gt;P&lt;/code&gt; goes down. The curve is smooth and roughly quadratic.&lt;/p&gt;

&lt;p&gt;Given a target duty point — say &lt;code&gt;Q_target = 5000 m³/h&lt;/code&gt; at &lt;code&gt;P_target = 400 Pa&lt;/code&gt;, with a tolerance band of ±15% on pressure — I want to, against a catalog of ~18,000 curves, return the subset that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Physically covers the target flow (the curve is defined at &lt;code&gt;Q = 5000&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Produces a pressure at that flow inside &lt;code&gt;[340, 460] Pa&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Is ranked by efficiency &lt;code&gt;η(Q_target)&lt;/code&gt;, because two fans that both "work" are not the same fan — the one consuming 2.2 kW instead of 4.0 kW pays for itself in 18 months.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why a lookup table is the wrong answer
&lt;/h2&gt;

&lt;p&gt;The naive move is to store each curve as a list of sampled &lt;code&gt;(Q, P)&lt;/code&gt; points — say 100 evenly spaced samples between &lt;code&gt;Q_min&lt;/code&gt; and &lt;code&gt;Q_max&lt;/code&gt;. Matching then becomes "find the row, interpolate linearly between the two nearest samples."&lt;/p&gt;

&lt;p&gt;For 18,141 curves at 100 samples each, that is 1.8 million rows. Postgres can chew through that, but you are paying three costs you did not have to pay:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Storage is inflated.&lt;/strong&gt; Each curve that actually fits in 5 floats is now 200 floats.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every read is an interpolation&lt;/strong&gt;, which introduces piecewise-linear error between sample points.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scaling the curve becomes awkward.&lt;/strong&gt; Fans run at variable speeds via VFDs; affinity laws say &lt;code&gt;Q ∝ n&lt;/code&gt; and &lt;code&gt;P ∝ n²&lt;/code&gt;. Applied to a polynomial, that is a one-line coefficient transform. Applied to 100 samples, it is 100 multiplications plus a full re-sampling grid.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Storage: a tiny array of coefficients
&lt;/h2&gt;

&lt;p&gt;A fan curve is well-approximated by a polynomial of modest degree:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;P(Q) = a_0 + a_1·Q + a_2·Q² + a_3·Q³ + ... + a_n·Qⁿ
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In practice, degree 3–5 is enough. Catalogs I imported use degree 6 at the high end. Each curve is stored as an array of 7 floats.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;fan_curves&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;             &lt;span class="n"&gt;bigserial&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;fan_id&lt;/span&gt;         &lt;span class="nb"&gt;bigint&lt;/span&gt;   &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;label&lt;/span&gt;          &lt;span class="nb"&gt;text&lt;/span&gt;     &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;coeffs&lt;/span&gt;         &lt;span class="nb"&gt;double&lt;/span&gt; &lt;span class="nb"&gt;precision&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;q_min&lt;/span&gt;          &lt;span class="nb"&gt;double&lt;/span&gt; &lt;span class="nb"&gt;precision&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;q_max&lt;/span&gt;          &lt;span class="nb"&gt;double&lt;/span&gt; &lt;span class="nb"&gt;precision&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;eta_xpoints&lt;/span&gt;    &lt;span class="nb"&gt;double&lt;/span&gt; &lt;span class="nb"&gt;precision&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;eta_values&lt;/span&gt;     &lt;span class="nb"&gt;double&lt;/span&gt; &lt;span class="nb"&gt;precision&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;n_nominal&lt;/span&gt;      &lt;span class="nb"&gt;double&lt;/span&gt; &lt;span class="nb"&gt;precision&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;fan_type&lt;/span&gt;       &lt;span class="nb"&gt;text&lt;/span&gt;     &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;fan_curves_q_range_idx&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;fan_curves&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q_min&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;q_max&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Evaluation: Horner, not &lt;code&gt;Math.pow&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The wrong way:&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="c1"&gt;// DON'T do this&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;p&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="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&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="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;coeffs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;coeffs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&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;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&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;Horner's method: same polynomial, computed right-to-left with one multiply and one add per coefficient. No &lt;code&gt;pow&lt;/code&gt;:&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;evaluatePolynomial&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;coeffs&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;q&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;coeffs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;result&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="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;coeffs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;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;i&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;coeffs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&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="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFinite&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="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&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;result&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;On an M2 laptop this evaluates degree-6 in ~30 ns. For 18,141 curves the full batch is ~540 µs. The rest of the 4 ms budget is Postgres I/O.&lt;/p&gt;

&lt;h2&gt;
  
  
  The matching loop
&lt;/h2&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;matchDuty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;curves&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Curve&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;duty&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Duty&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Match&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;qTarget&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pTarget&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tolerance&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;duty&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;pMin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pTarget&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;tolerance&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;pMax&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pTarget&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;tolerance&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;results&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Match&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="k"&gt;for &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;c&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;curves&lt;/span&gt;&lt;span class="p"&gt;)&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;qTarget&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;qMin&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;qTarget&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;qMax&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&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;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;evaluatePolynomial&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;coeffs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;qTarget&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;p&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;pMin&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;pMax&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&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;eta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;interpolateEta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;etaXpoints&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;etaValues&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;qTarget&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;curve&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;pressureAtQ&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;deviation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;pTarget&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;pTarget&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;eta&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&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="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eta&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eta&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deviation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&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;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deviation&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;results&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;Benchmark on production catalog:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;Mean time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Postgres range-filter + row transfer&lt;/td&gt;
&lt;td&gt;2.8 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Horner evaluation, 2,100 rows mean&lt;/td&gt;
&lt;td&gt;0.6 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;η interpolation and sort&lt;/td&gt;
&lt;td&gt;0.5 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total (warm cache)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.9 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The three gotchas that each cost a week
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Pressure and efficiency are separate functions
&lt;/h3&gt;

&lt;p&gt;Assuming &lt;code&gt;η(Q)&lt;/code&gt; can be derived from &lt;code&gt;P(Q)&lt;/code&gt; via a shaft-power formula is the single most common mistake in naive fan-selector tutorials. It cannot. Pressure drops roughly quadratically; efficiency has a bell shape peaking around 70% of &lt;code&gt;Q_max&lt;/code&gt;. Two curves with similar pressure at a duty point can have drastically different efficiencies there. Store &lt;code&gt;η&lt;/code&gt; as its own thing — my data ships as vectors of sampled measurement points, linearly interpolated.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Polynomial degree varies per manufacturer
&lt;/h3&gt;

&lt;p&gt;One catalog fits degree-3. Another fits degree-5. A third went with degree-6. Zero-pad every curve to the max degree so the hot loop has a fixed shape and the JS engine keeps the inner loop monomorphic:&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;MAX_DEGREE&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;normalizeCoeffs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;coeffs&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;MAX_DEGREE&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="nf"&gt;fill&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="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&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="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;coeffs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;MAX_DEGREE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;coeffs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&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;out&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;Measured: ~30% faster on the full batch after zero-padding.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Extrapolation is silently wrong
&lt;/h3&gt;

&lt;p&gt;A polynomial evaluated outside its fit domain does not return an error. It returns a physically meaningless number — often negative pressure or a value implying the fan produces more air at higher static pressure, which is not how fans work. The domain check is a correctness guarantee, not an optimization:&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;qTarget&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;qMin&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;qTarget&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;qMax&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I skipped this originally. A user asked for 200 m³/h on a fan whose curve started at 800. Engine reported 1200 Pa. The fan would stall in reality.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scaling across RPM — the affinity law bonus
&lt;/h2&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;scaleByRpm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;coeffs&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;nBase&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;nTarget&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;nTarget&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;nBase&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;coeffs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&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;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;i&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;One pass over the array. The stored curve stays unmodified.&lt;/p&gt;

&lt;h2&gt;
  
  
  Benchmarks, cold and hot
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;p50&lt;/th&gt;
&lt;th&gt;p95&lt;/th&gt;
&lt;th&gt;p99&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cold cache, first query&lt;/td&gt;
&lt;td&gt;38 ms&lt;/td&gt;
&lt;td&gt;62 ms&lt;/td&gt;
&lt;td&gt;94 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Warm cache&lt;/td&gt;
&lt;td&gt;4.2 ms&lt;/td&gt;
&lt;td&gt;6.1 ms&lt;/td&gt;
&lt;td&gt;9.8 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Warm + query cache hit&lt;/td&gt;
&lt;td&gt;0.9 ms&lt;/td&gt;
&lt;td&gt;1.4 ms&lt;/td&gt;
&lt;td&gt;2.3 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What is in the repo, and what is next
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://github.com/goncharovart/polynomial-fan-matcher" rel="noopener noreferrer"&gt;polynomial-fan-matcher repo&lt;/a&gt; extracts the evaluation and matching core from production. It ships:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;evaluatePolynomial(coeffs, q)&lt;/code&gt; — Horner in 10 lines&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;scaleByRpm(coeffs, nBase, nTarget)&lt;/code&gt; — affinity-law transform on coefficients&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;matchDuty(curves, duty)&lt;/code&gt; — the matching loop from this post&lt;/li&gt;
&lt;li&gt;Tests against manufacturer reference data&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This OSS module is one subsystem extracted from &lt;a href="https://wentmarket.ru" rel="noopener noreferrer"&gt;wentmarket.ru&lt;/a&gt; — a vertical B2B HVAC portal I built over ~2 months. The full platform combines a marketplace (2K+ SKUs, B2B/B2C commerce with 1C ERP and Bitrix24 CRM integration), an engineer's toolkit (17 calculation engines: fan selection, VFD, LCC, acoustic silencers, AHU designer, duct losses, 3D BIM exports), and a Telegram ecosystem (27 bot commands + Mini App). I used Claude Code as a tool-accelerator for boilerplate, test generation, and first-pass bug hunting — architecture, stack choices, HVAC domain modeling, and production QA are mine. If you work on HVAC tooling in the West and want to talk about collaboration, licensing, or engineering work, I am reachable at &lt;strong&gt;&lt;a href="mailto:goncharov.artur.02@gmail.com"&gt;goncharov.artur.02@gmail.com&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Store fan curves as polynomial coefficient arrays in Postgres, not sampled lookup tables.&lt;/li&gt;
&lt;li&gt;Evaluate with Horner's method; 10 lines, no &lt;code&gt;Math.pow&lt;/code&gt;, stable floating-point.&lt;/li&gt;
&lt;li&gt;Index on &lt;code&gt;(q_min, q_max)&lt;/code&gt; and domain-check every query.&lt;/li&gt;
&lt;li&gt;Keep pressure and efficiency as independent curves.&lt;/li&gt;
&lt;li&gt;An extract of the production engine is open source at &lt;a href="https://github.com/goncharovart/polynomial-fan-matcher" rel="noopener noreferrer"&gt;github.com/goncharovart/polynomial-fan-matcher&lt;/a&gt;; it matches 18k curves in ~4 ms.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>database</category>
      <category>performance</category>
      <category>postgres</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
