<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[ Anders Lee's Dev Notes]]></title><description><![CDATA[Notes from building small, fast reference sites and tools: data, Astro, Cloudflare, Docker.]]></description><link>https://anders.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Mon, 02 Nov 2026 17:44:32 GMT</lastBuildDate><atom:link href="https://anders.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Modeling Florida's Amendment 3 property tax exemption, county by county, in Python]]></title><description><![CDATA[Florida votes on Amendment 3 on November 3, 2026. The ballot summary is one sentence long, but the question every homeowner actually has — what does this…]]></description><link>https://anders.hashnode.dev/modeling-florida-s-amendment-3-property-tax-exemption-county-by-county-in-python</link><guid isPermaLink="true">https://anders.hashnode.dev/modeling-florida-s-amendment-3-property-tax-exemption-county-by-county-in-python</guid><category><![CDATA[Python]]></category><category><![CDATA[datascience]]></category><category><![CDATA[#OpenData]]></category><category><![CDATA[CivicTech]]></category><dc:creator><![CDATA[anders lee]]></dc:creator><pubDate>Sat, 31 Oct 2026 09:08:47 GMT</pubDate><content:encoded><![CDATA[<p>Florida votes on Amendment 3 on November 3, 2026. The ballot summary is one sentence long, but the question every homeowner actually has — <em>what does this do to my bill?</em> — depends on two numbers that differ by county: the school millage and everything else. So I modeled it for all 67 counties.</p>
<h2>The rule, in code</h2>
<p>Florida bills split into two kinds of millage (dollars of tax per $1,000 of assessed value): <strong>school</strong> and <strong>non-school</strong> (county, city, special districts). Today a homestead gets two exemptions:</p>
<ul>
<li>$25,000 off the assessed value for <em>all</em> taxes;</li>
<li>a second exemption, up to $25,722 for 2025 (indexed to inflation), on value between $50,000 and $75,000 — <strong>non-school taxes only</strong>.</li>
</ul>
<p>Amendment 3 replaces both. Per the Property Appraisers' Association of Florida's FAQ, a homestead would get $25,000 off for school taxes (unchanged) and up to <strong>$150,000 in 2027, then $250,000 in 2028</strong> off for non-school taxes, indexed from 2029.</p>
<pre><code class="language-python">def current_tax(value, school_mills, other_mills):
    first = min(value, 25_000)
    second = min(max(value - 50_000, 0), 25_722)
    return ((value - first) * school_mills
            + (value - first - second) * other_mills) / 1000

def amendment3_tax(value, school_mills, other_mills, non_school_exempt):
    school_taxable = value - min(value, 25_000)
    other_taxable = value - min(value, non_school_exempt)
    return (school_taxable * school_mills + other_taxable * other_mills) / 1000
</code></pre>
<p>Two things fall straight out of the code:</p>
<ol>
<li><strong>Savings are capped.</strong> Once the home is assessed at $250,000 or more, the extra exemption is fixed at about $199,278 of value ($250,000 minus today's $50,722). In dollars it can't exceed <code>199.278 × non-school mills</code>, so it is the same for a $300,000 home and a $3 million one.</li>
<li><strong>School taxes become most of the bill.</strong> In the median county, school millage makes up about 87% of what a median-value homestead would still owe in 2028.</li>
</ol>
<h2>The data</h2>
<p>The Florida Department of Revenue publishes each county's 2025 millage: school operating, school debt, county, municipal and special district averages. Splitting school from everything else:</p>
<pre><code class="language-python">import pandas as pd

fl = pd.read_csv("florida_millage_2025.csv")
fl["school"] = fl["school_operating"] + fl["school_debt"]
fl["other"] = fl["total_mills"] - fl["school"]
</code></pre>
<p>For a "typical" home I used the Census ACS median value of owner-occupied homes in each county. That's a market value, not an assessed value, which matters (see below).</p>
<h2>What it shows</h2>
<p>At 2025 millage, for a homestead at the county's median home value:</p>
<table>
<thead>
<tr>
<th>County</th>
<th align="right">Median value</th>
<th align="right">Tax now</th>
<th align="right">2027</th>
<th align="right">2028</th>
<th align="right">Saves (2028)</th>
</tr>
</thead>
<tbody><tr>
<td>Miami-Dade</td>
<td align="right">$463,000</td>
<td align="right">$7,793</td>
<td align="right">$6,616</td>
<td align="right">$5,431</td>
<td align="right">$2,363</td>
</tr>
<tr>
<td>Broward</td>
<td align="right">$414,600</td>
<td align="right">$7,395</td>
<td align="right">$6,067</td>
<td align="right">$4,729</td>
<td align="right">$2,666</td>
</tr>
<tr>
<td>Palm Beach</td>
<td align="right">$447,300</td>
<td align="right">$7,099</td>
<td align="right">$5,990</td>
<td align="right">$4,873</td>
<td align="right">$2,226</td>
</tr>
<tr>
<td>Hillsborough</td>
<td align="right">$371,500</td>
<td align="right">$6,211</td>
<td align="right">$4,969</td>
<td align="right">$3,717</td>
<td align="right">$2,494</td>
</tr>
<tr>
<td>Orange</td>
<td align="right">$390,100</td>
<td align="right">$6,074</td>
<td align="right">$4,986</td>
<td align="right">$3,890</td>
<td align="right">$2,184</td>
</tr>
</tbody></table>
<p>Across all 67 counties:</p>
<ul>
<li>The median county saves about <strong>$1,559 a year</strong> in 2028, and <strong>$949</strong> in 2027.</li>
<li>In percent, cuts range from <strong>17% to 67%</strong> of the bill (median 53%). The biggest percentage cuts are in cheaper counties; the biggest dollar savings are where non-school millage is high and homes are worth more than $250,000 (St. Lucie, about $3,041; Alachua, about $2,917).</li>
<li>In 9 counties the median home is worth less than $150,000, so the 2027 exemption already covers it and the bill drops to school taxes only.</li>
</ul>
<h2>What the model leaves out</h2>
<p>This is the part I'd want a reader to take seriously:</p>
<ul>
<li><strong>Assessed value isn't market value.</strong> Save Our Homes caps a homestead's assessed value growth at 3% a year (or CPI). Someone who has owned since 2010 may be assessed at half the market value, so their dollar savings are smaller. The ACS medians overstate assessed value for long-time owners.</li>
<li><strong>Rates aren't fixed.</strong> The amendment changes taxable value, not millage. Because the taxable base shrinks, the "rolled-back rate" on 2027 TRIM notices effectively becomes a rolled-up rate, and each county, city and district sets its own millage in its budget. Holding 2025 rates constant is a lower bound on what local governments would need to change.</li>
<li><strong>New residents wait.</strong> People who become Florida residents on or after January 1, 2027 start with a $50,000 non-school exemption and get the larger one from their fifth year.</li>
<li><strong>County averages.</strong> The millage is a county-wide average including cities; a home in an unincorporated area or in a specific city will differ.</li>
</ul>
<h2>Try it</h2>
<p>I put the model into a calculator with a county picker and the full 67-county table (sortable), so you can plug in the assessed value from your own TRIM notice: <a href="https://countypropertytaxes.com/florida-amendment-3-calculator/">https://countypropertytaxes.com/florida-amendment-3-calculator/</a></p>
<p>The underlying CSVs (Florida millage, plus Texas and North Carolina rates and Census medians for every county) are CC BY 4.0 on Kaggle, Hugging Face and Zenodo if you want to run your own version.</p>
]]></content:encoded></item><item><title><![CDATA[A 180-page reference site for the price of a domain (Astro + Cloudflare Workers)]]></title><description><![CDATA[Checking every row of the Indian steel section tables (IS 808) with physics found typos that survived 30 years, and a 1964 value most weight charts st]]></description><link>https://anders.hashnode.dev/a-180-page-reference-site-for-the-price-of-a-domain-astro-cloudflare-workers</link><guid isPermaLink="true">https://anders.hashnode.dev/a-180-page-reference-site-for-the-price-of-a-domain-astro-cloudflare-workers</guid><category><![CDATA[Python]]></category><category><![CDATA[datascience]]></category><category><![CDATA[engineering]]></category><category><![CDATA[#OpenData]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[pdf]]></category><category><![CDATA[webdev]]></category><category><![CDATA[Tutorial]]></category><category><![CDATA[Astro]]></category><category><![CDATA[cloudflare]]></category><category><![CDATA[SEO]]></category><dc:creator><![CDATA[anders lee]]></dc:creator><pubDate>Fri, 30 Oct 2026 13:00:33 GMT</pubDate><content:encoded><![CDATA[<p>I built <a href="https://steelweight-calculator.com/">steelweight-calculator.com</a>, a reference site for steel section weights: one page per beam, channel and angle, weight charts, pipe schedules, rebar and sheet gauges, plus calculators. It has about 180 pages and 570 files today and costs nothing to run apart from the domain. Here is the setup and a few things I would do the same way again.</p>
<h2>Everything is generated from data</h2>
<p>The site is <a href="https://astro.build/">Astro</a> in fully static mode. A handful of Python scripts turn the sources (standards, spreadsheets, open-source libraries) into JSON in <code>src/data/</code>, and each JSON row becomes a page through <code>getStaticPaths()</code>:</p>
<pre><code class="language-ts">// src/pages/beams/[slug].astro
export function getStaticPaths() {
  return publishedOfKind("beam").map((section) =&gt; ({ params: { slug: section.slug }, props: { section } }));
}
</code></pre>
<p>The page then renders everything from that one row: weight in kg/m and lb/ft, a table of standard lengths, surface area for painting, the nearest equivalents in other standards, and an SVG cross-section drawn to scale from the dimensions. No images to maintain; change the data and every drawing follows.</p>
<p>The calculators are small TypeScript modules that Astro bundles into the page, sharing the same weight functions as the build (no UI framework on the client). Astro inlines the 4 KB stylesheet with <code>build.inlineStylesheets: "always"</code>, so there is no render-blocking CSS request.</p>
<h2>Hosting: Workers static assets, no Worker code</h2>
<p>Cloudflare Workers can serve a directory of static files without any script:</p>
<pre><code class="language-jsonc">// wrangler.jsonc
{
  "name": "steelweight-calculator",
  "compatibility_date": "2026-09-01",
  "assets": { "directory": "./dist", "not_found_handling": "404-page" },
  "workers_dev": false,
  "preview_urls": false
}
</code></pre>
<p><code>npm run build &amp;&amp; wrangler deploy</code> uploads only changed files. Two settings matter for SEO: <code>workers_dev: false</code> and <code>preview_urls: false</code>, so the site is not also served (and indexed) on a <code>*.workers.dev</code> address. The custom domain and the www → apex redirect are set up once in the dashboard.</p>
<p>With <code>trailingSlash: "always"</code> and <code>build.format: "directory"</code>, every URL has exactly one form, which keeps canonical URLs, the sitemap and internal links consistent.</p>
<h2>Release pages in batches</h2>
<p>There are 222 section pages with real search demand, but a new domain publishing hundreds of near-identical pages on day one is asking to be ignored. So the build only renders the first N:</p>
<pre><code class="language-ts">export const PUBLISHED = published.sections.slice(0, release.sections).map(findSection);
</code></pre>
<p><code>published.json</code> lists the sections by search demand (from Keyword Planner exports), interleaving Indian, European and American sections so every batch covers all three markets. <code>release.json</code> holds N; raising it by 50 a week is the whole release process. Links on other pages use an <code>isPublished()</code> check so nothing points at a page that is not built yet, and a link checker over <code>dist/</code> runs before every deploy.</p>
<h2>Images without an image editor</h2>
<p>Each page gets a 1200×630 social preview showing its key number ("ISMB 200 · 24.17 kg/m"), and the chart pages get a printable PDF. Both are made with Playwright after the build: a script walks <code>dist/</code>, pulls the <code>&lt;h1&gt;</code> and the answer line out of each page, fills an HTML template and takes a screenshot. The PDFs are the chart pages printed with a print stylesheet.</p>
<p>The same trick makes 1000×1500 Pinterest images: the page's drawing or the first rows of its table, trimmed in the browser until they fit:</p>
<pre><code class="language-js">for (;;) {
  more.textContent = hidden &gt; 0 ? `+ ${hidden} more rows on the page` : "";
  if (main.scrollHeight &lt;= main.clientHeight || rows.length &lt;= 3) break;
  rows.pop().remove();
}
</code></pre>
<p>An RSS feed lists those images, and Pinterest turns each new feed item into a pin. Because each image keeps the date it was first generated, only pages from the new batch show up as new.</p>
<h2>Data you can check</h2>
<p>The part I spent the most time on was not the site but the data: recomputing derived columns (mass from area, radius of gyration from inertia) for every row found misprints in the standards themselves. I wrote that up separately, and the cleaned data is on GitHub as CSV/JSON: <a href="https://github.com/imrubinshao/steel-section-weights">steel-section-weights</a>.</p>
<h2>Numbers so far</h2>
<ul>
<li>About 180 pages built, 34 MB including images and PDFs, Lighthouse accessibility 100.</li>
<li>Cost: the domain. Cloudflare's free plan covers the hosting, analytics and DNS.</li>
<li>Deploy: one command, about 15 seconds of build and a few seconds of upload.</li>
</ul>
<p>If you are building something similar (a calculator or reference site from structured data), the combination of a static generator, a data folder, and a scheduled <code>release.json</code> bump has been very low maintenance.</p>
]]></content:encoded></item><item><title><![CDATA[Three equations that found misprints in a national steel standard]]></title><description><![CDATA[Checking every row of the Indian steel section tables (IS 808) with physics found typos that survived 30 years, and a 1964 value most charts quote.]]></description><link>https://anders.hashnode.dev/three-equations-that-found-misprints-in-a-national-steel-standard</link><guid isPermaLink="true">https://anders.hashnode.dev/three-equations-that-found-misprints-in-a-national-steel-standard</guid><category><![CDATA[Python]]></category><category><![CDATA[datascience]]></category><category><![CDATA[engineering]]></category><category><![CDATA[#OpenData]]></category><dc:creator><![CDATA[anders lee]]></dc:creator><pubDate>Tue, 27 Oct 2026 10:24:06 GMT</pubDate><content:encoded><![CDATA[<p>I needed clean data for steel beams, channels and angles: weight per metre, dimensions and section properties. For Indian sections the source is IS 808, the national standard, made public by <a href="http://Public.Resource.Org">Public.Resource.Org</a>. The 2021 edition is a born-digital PDF, the 1989 edition and the 1964 handbook are scans.</p>
<p>Transcribing tables is easy. Knowing the transcription (and the printed table) is right is the interesting part. Section tables have a nice property: their columns are not independent. A few physical relations tie them together, so every row can check itself.</p>
<h2>The three relations</h2>
<p><strong>1. Mass follows from area.</strong> Steel is 7850 kg/m³. One metre of a section with area A cm² is A × 10⁻⁴ m³, so</p>
<pre><code class="language-plaintext">mass (kg/m) = 0.785 × A (cm²)
</code></pre>
<p><strong>2. Radius of gyration follows from inertia and area.</strong></p>
<pre><code class="language-plaintext">r = sqrt(I / A)
</code></pre>
<p><strong>3. Elastic modulus follows from inertia and depth</strong> (for doubly symmetric beams, about the major axis):</p>
<pre><code class="language-plaintext">Zx = Ix / (D / 2)
</code></pre>
<p>That is enough to check most columns of most rows. In Python, the core of it is a dozen lines:</p>
<pre><code class="language-python">import math

TOL = 0.02  # tables print 3 to 4 significant figures

def close(printed, derived, tol=TOL):
    # small values are printed to 0.1, so allow an absolute slack too
    return abs(printed - derived) &lt;= tol * abs(derived) + 0.06

def check(row):
    problems = []
    if not close(row["M"], 0.785 * row["A"]):
        problems.append(("M", row["M"], round(0.785 * row["A"], 2)))
    for r, i in (("rx", "Ix"), ("ry", "Iy")):
        derived = math.sqrt(row[i] / row["A"])
        if not close(row[r], derived):
            problems.append((r, row[r], round(derived, 2)))
    if row.get("D") and not close(row["Zx"], row["Ix"] / (row["D"] / 20)):  # D in mm, Z in cm³
        problems.append(("Zx", row["Zx"], round(row["Ix"] / (row["D"] / 20), 1)))
    return problems
</code></pre>
<p>Run it over every row and anything it flags is either a transcription error (mine) or a misprint (theirs). I fixed mine; the rest went into an <code>errata.csv</code> next to the source data, so the source CSVs keep the printed value and the build applies the correction.</p>
<h2>What it found</h2>
<p>In <strong>IS 808:1989</strong> (including its 1992 amendment):</p>
<table>
<thead>
<tr>
<th>Section</th>
<th>Column</th>
<th>Printed</th>
<th>From the relations</th>
<th>Check</th>
</tr>
</thead>
<tbody><tr>
<td>MC 100</td>
<td>Zx</td>
<td>33.5</td>
<td>38.4</td>
<td>Ix / (D/2) = 192 / 5.0</td>
</tr>
<tr>
<td>MC 175 (heavy)</td>
<td>mass</td>
<td>22.7</td>
<td>21.7</td>
<td>0.785 × 27.6</td>
</tr>
<tr>
<td>MC 250</td>
<td>ry</td>
<td>2.37</td>
<td>2.33</td>
<td>√(211 / 39.0)</td>
</tr>
<tr>
<td>ISA 35×35×6</td>
<td>Iu</td>
<td>5.5</td>
<td>6.4</td>
<td>also smaller than the 5 mm angle's 5.6</td>
</tr>
<tr>
<td>ISA 65×65×4</td>
<td>Ix, Iu</td>
<td>21.70, 34.50</td>
<td>20.2, 32.0</td>
<td>the row added by the amendment</td>
</tr>
</tbody></table>
<p>In <strong>IS 808:2021</strong>:</p>
<table>
<thead>
<tr>
<th>Section</th>
<th>Column</th>
<th>Printed</th>
<th>Correct</th>
<th>Note</th>
</tr>
</thead>
<tbody><tr>
<td>MC 75</td>
<td>rz</td>
<td>39.4 mm</td>
<td>29.4 mm</td>
<td>√(78.2×10⁴ / 908); the 1989 edition had 2.94 cm</td>
</tr>
<tr>
<td>MC 175*</td>
<td>mass</td>
<td>22.7</td>
<td>21.4</td>
<td>0.785 × 27.3 — <strong>the same 22.7 as in 1989</strong></td>
</tr>
<tr>
<td>every ISA ≥ 100 mm</td>
<td>rv</td>
<td>×10</td>
<td></td>
<td>e.g. 200.0 printed where √(Iv / A) gives 20.0 mm</td>
</tr>
</tbody></table>
<p>The MC 175 one is my favourite: a mass that does not match its own area, carried from the 1989 table into the 2021 one, 32 years later.</p>
<p>Big tables from other places are not immune either. The AISC Shapes Database (the American reference spreadsheet) has its metric and US columns side by side; comparing them row by row found one cell, the perimeter of S24×90, with an extra zero (18400 mm instead of 1840).</p>
<h2>The 1964 value everybody still uses</h2>
<p>The relations also explained a long-standing confusion. Search for the weight of ISMB 200 and you get <strong>25.4 kg/m</strong> from most charts and <strong>24.2 kg/m</strong> from a few. Both are "right":</p>
<table>
<thead>
<tr>
<th>Edition</th>
<th>Flange thickness</th>
<th>Area</th>
<th>Mass</th>
</tr>
</thead>
<tbody><tr>
<td>SP 6(1):1964 handbook</td>
<td>10.8 mm</td>
<td>32.33 cm²</td>
<td>25.4 kg/m</td>
</tr>
<tr>
<td>IS 808:1989</td>
<td>10.0 mm</td>
<td>30.8 cm²</td>
<td>24.2 kg/m</td>
</tr>
<tr>
<td>IS 808:2021</td>
<td>10.0 mm</td>
<td>30.8 cm²</td>
<td>24.17 kg/m</td>
</tr>
</tbody></table>
<p>The 1989 revision thinned the flange. Charts copied from the old handbook never updated, so a 12 m beam is quoted 15 kg heavier than what is rolled today. The same happened to ISMB 100 (11.5 → 8.9 kg/m) and ISMB 300 (44.2 → 46.0 kg/m, heavier this time).</p>
<h2>Takeaways for any engineering dataset</h2>
<ul>
<li><strong>Look for redundancy.</strong> Engineering tables usually contain derived columns. Recompute them instead of trusting them; disagreement is signal.</li>
<li><strong>Keep the printed value.</strong> Store corrections separately (<code>errata.csv</code> with the reason and the arithmetic) rather than editing the source, so anyone can audit the change.</li>
<li><strong>Check two sources against each other</strong> where they exist: two open libraries for European sections, metric vs US columns for AISC.</li>
<li><strong>Old editions matter.</strong> If users quote a number that does not match the current standard, the previous edition is often why — and showing both is more useful than silently picking one.</li>
</ul>
<p>The cleaned tables are on GitHub as CSV/JSON: <a href="https://github.com/imrubinshao/steel-section-weights">steel-section-weights</a>. I also built them into a free reference site with a weight calculator and charts that show all three editions side by side: <a href="https://steelweight-calculator.com/charts/ismb-weight-chart/">steelweight-calculator.com</a>.</p>
]]></content:encoded></item></channel></rss>