<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://bunkum.us/feed.xml" rel="self" type="application/atom+xml" /><link href="https://bunkum.us/" rel="alternate" type="text/html" /><updated>2026-06-19T04:01:32+00:00</updated><id>https://bunkum.us/feed.xml</id><entry><title type="html">Cocktail Optimization, an Integer Programming Problem</title><link href="https://bunkum.us/2026/06/18/cocktail-ingredients-milp.html" rel="alternate" type="text/html" title="Cocktail Optimization, an Integer Programming Problem" /><published>2026-06-18T00:00:00+00:00</published><updated>2026-06-18T00:00:00+00:00</updated><id>https://bunkum.us/2026/06/18/cocktail-ingredients-milp</id><content type="html" xml:base="https://bunkum.us/2026/06/18/cocktail-ingredients-milp.html"><![CDATA[<p>I’ve been interested in <a href="https://en.wikipedia.org/wiki/Integer_programming">integer programming</a>
problems for a long time (they the most interesting problems in <a href="https://github.com/dedupeio/dedupe/">dedupe</a>).
In the past, I approached them by writing custom <a href="https://en.wikipedia.org/wiki/Branch_and_bound">branch-and-bound algorithms</a>.</p>
<p>I have been using <a href="https://developers.google.com/optimization">Google’s OR Tools</a> for
a project that involves a lot of vehicle routing, and I started to wonder how these mixed integer
linear programming solvers would do against my lovingly crafted algorithms.</p>
<p>They utterly surpass them. These solvers are technical marvels, containing the congealed
knowledge of thousands of hours of research and engineering. Of course my code wasn’t
really going to compete.</p>
<p>A few years ago, I wrote <a href="/2025/01/21/cocktail-ingredients-optimizer.html">a branch-and-bound solver for the problem of maximizing the number
of cocktails you can make with certain number of ingredients on your cocktail tray</a>.
I was pretty proud of it, but if you set your ingredient budget to 30, it will take many minutes to find
the optimum solution, and it would basically never stop looking for a better one.</p>
<p>As you can
see below, with <a href="https://github.com/jvail/glpk.js/">glpk.js</a>, it takes milliseconds to find
a final optimum.</p>

<div id="cell-1" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2022-11-21-trends-in-tier-scores-at-cps-selective-enrollment-high-schools/cell-1.png" alt="chart" style="max-width:100%"></div>

<p>With <span data-reactive="2-0">101,000—104,000</span> ingredients, you can make <span data-reactive="2-1">145,000—149,000</span> cocktail(s).</p>

<div id="cell-3" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2022-09-22-cps-decline-and-market-share/cell-3.png" alt="chart" style="max-width:100%"></div>

<div id="cell-4" class="reactive-cell"><table>
<thead>
<tr>
<th>Cocktail</th>
<th>Ingredients</th>
</tr>
</thead>
</table>
<p>Here’s the shopping list:</p>
<table>
<thead>
<tr>
<th>Ingredient</th>
<th>Number of Cocktails</th>
</tr>
</thead>
</table>
</div>

<div id="cell-5" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2022-09-22-cps-decline-and-market-share/cell-5.png" alt="chart" style="max-width:100%"></div>

<div id="cell-6" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2024-11-26-chicago-policing-force-size-and-budgets/cell-6.png" alt="chart" style="max-width:100%"></div>

<div id="cell-7" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2024-01-01-michigan-property-tax-simulator/cell-7.png" alt="chart" style="max-width:100%"></div>

<div id="cell-8" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2023-06-04-chicago-population-pyramids/cell-8.png" alt="chart" style="max-width:100%"></div>

<div id="cell-9" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2023-11-01-districting-for-the-chicago-public-schools-elected-board/cell-9.png" alt="chart" style="max-width:100%"></div>

<div id="cell-10" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2024-01-01-michigan-property-tax-simulator/cell-10.png" alt="chart" style="max-width:100%"></div>

<div id="cell-11" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2025-04-01-dte-outages/cell-11.png" alt="chart" style="max-width:100%"></div>

<div id="cell-12" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2023-11-01-districting-for-the-chicago-public-schools-elected-board/cell-12.png" alt="chart" style="max-width:100%"></div>

<div id="cell-13" class="reactive-cell"></div>

<div id="cell-14" class="reactive-cell"></div>

<div id="cell-15" class="reactive-cell"></div>

<script type="module">
import {define, main, Plot, Inputs, d3} from "/assets/js/reactive-runtime.js";
// Register Plot, Inputs, and d3 as reactive variables so cells that
// reference them resolve through the dependency graph (not runtime builtins).
main.variable().define("Plot", [], () => Plot);
main.variable().define("Inputs", [], () => Inputs);
main.variable().define("d3", [], () => d3);
// Hydrate an inline ${…}: bind its reactive value to its placeholder span,
// updating only that span — never re-render the surrounding (static) prose.
function hydrate(ref, inputs, body) {
  const el = document.querySelector(`[data-reactive="${ref}"]`);
  if (!el) return;
  main.variable({
    pending() {},
    rejected(error) { console.error(error); },
    fulfilled(value) {
      el.replaceChildren(value instanceof Node ? value : document.createTextNode(value == null ? "" : String(value)));
    }
  }).define(null, inputs, body);
}
define(
  {root: document.getElementById(`cell-1`), expanded: [], variables: []},
  {
    id: 1,
    body: (view,Inputs) => {
const num_ingredients = view(
  Inputs.range([2, 129], {
    label: "Number of Ingredients",
    step: 1,
    value: 30,
  }),
);
return {num_ingredients};
},
    inputs: ["view","Inputs"],
    outputs: ["num_ingredients"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

hydrate("2-0", ["num_ingredients"], (num_ingredients) => {
return (
num_ingredients
)
});
hydrate("2-1", ["solution"], (solution) => {
return (
solution.covered.length
)
});
define(
  {root: document.getElementById(`cell-3`), expanded: [], variables: []},
  {
    id: 3,
    body: (display,Plot,curve,num_ingredients) => {
display(
  Plot.plot({
    x: { label: "Ingredients on your tray →", domain: [2, 129] },
    y: { label: "↑ Cocktails you can make", domain: [0, 104], grid: true },
    marks: [
      Plot.line(curve, {
        x: "ingredients",
        y: "cocktails",
        stroke: "#552222",
      }),
      // Mark where the slider currently sits on the curve.
      Plot.ruleX([num_ingredients], { stroke: "#552222", strokeOpacity: 0.4 }),
      Plot.dot(
        curve.filter((d) => d.ingredients === num_ingredients),
        { x: "ingredients", y: "cocktails", fill: "#552222", r: 4 },
      ),
      Plot.text(
        curve.filter((d) => d.ingredients === num_ingredients),
        {
          x: "ingredients",
          y: "cocktails",
          text: (d) => d.cocktails,
          dy: -10,
          fill: "#552222",
          fontWeight: "bold",
        },
      ),
    ],
  }),
);
},
    inputs: ["display","Plot","curve","num_ingredients"],
    outputs: [],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-4`), expanded: [], variables: []},
  {
    id: 4,
    body: (md,orderedCovered,shoppingList) => {
return (
md`

| Cocktail | Ingredients |
|----------|-------------|
${orderedCovered.map((c) => `| ${c.name} | ${c.ingredients.join(", ")} |`).join("\n")}

Here's the shopping list:

| Ingredient | Number of Cocktails |
|------------|---------------------|
${shoppingList.map((row) => `| ${row.join(" | ")} |`).join("\n")}

`
)
},
    inputs: ["md","orderedCovered","shoppingList"],
    outputs: [],
    output: null,
    autodisplay: true,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-5`), expanded: [], variables: []},
  {
    id: 5,
    body: (d3,solution,currentRun,presence,num_ingredients) => {
const shoppingList = (() => {
  const counts = d3.rollups(
    solution.covered.flatMap((cocktail) => cocktail.ingredients),
    (uses) => uses.length, // how many chosen cocktails use this ingredient
    (ingredient) => ingredient,
  );
  // Order by each ingredient's current run (consecutive budgets it's been bought,
  // counting down from the slider); ties broken alphabetically.
  return counts.sort(
    (a, b) =>
      currentRun(presence.ingredientPresence, b[0], num_ingredients) -
        currentRun(presence.ingredientPresence, a[0], num_ingredients) ||
      a[0].localeCompare(b[0]),
  );
})();
return {shoppingList};
},
    inputs: ["d3","solution","currentRun","presence","num_ingredients"],
    outputs: ["shoppingList"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-6`), expanded: [], variables: []},
  {
    id: 6,
    body: async (glpk,problem,budget) => {
const solution = await (async () => {
  const res = await glpk.solve(
    {
      name: "cocktails",
      objective: problem.objective,
      subjectTo: [...problem.coverage, budget],
      binaries: problem.binaries,
    },
    { msglev: glpk.GLP_MSG_OFF },
  );

  const covered = problem.recipes
    .filter(([name]) => res.result.vars[`make ${name}`] > 0.5)
    .map(([name, ingredients]) => ({ name, ingredients }))
    .sort((a, b) => a.name.localeCompare(b.name));

  return { covered, objective: Math.round(res.result.z) };
})();
return {solution};
},
    inputs: ["glpk","problem","budget"],
    outputs: ["solution"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-7`), expanded: [], variables: []},
  {
    id: 7,
    body: (solution,currentRun,presence,num_ingredients) => {
// Cocktails ordered by their current run — how many budgets in a row (counting
// down from the slider) they've stayed in the solution — longest first. A
// cocktail on the list across many budgets sits at the top; one that just
// (re)appeared drops to the bottom. (Alphabetical until the sweep lands.)
const orderedCovered = [...solution.covered].sort(
  (a, b) =>
    currentRun(presence.cocktailPresence, b.name, num_ingredients) -
      currentRun(presence.cocktailPresence, a.name, num_ingredients) ||
    a.name.localeCompare(b.name),
);
return {orderedCovered};
},
    inputs: ["solution","currentRun","presence","num_ingredients"],
    outputs: ["orderedCovered"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-8`), expanded: [], variables: []},
  {
    id: 8,
    body: () => {
// How many budgets in a row — counting DOWN from K — this key has stayed in the
// solution: its current run length. A single gap resets it to 0, so something
// that dropped off and came back starts its run over.
function currentRun(presence, key, K) {
  const seen = presence.get(key);
  if (!seen) return 0;
  let run = 0;
  for (let k = K; k >= 2 && seen.has(k); k--) run++;
  return run;
}
return {currentRun};
},
    inputs: [],
    outputs: ["currentRun"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-9`), expanded: [], variables: []},
  {
    id: 9,
    body: (problem,glpk,num_ingredients) => {
const budget = {
  name: "budget",
  vars: problem.ingredients.map((ingredient) => ({
    name: `buy ${ingredient}`,
    coef: 1,
  })),
  bnds: { type: glpk.GLP_UP, ub: num_ingredients }, // Σ buy[i] ≤ K
};
return {budget};
},
    inputs: ["problem","glpk","num_ingredients"],
    outputs: ["budget"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-10`), expanded: [], variables: []},
  {
    id: 10,
    body: () => {
// Solve the integer program for every budget from 2 to 129 once, up front. This
// gives the chart's curve and, as a byproduct, a record of which budgets each
// cocktail / ingredient is chosen at (its "presence"). The tables use that to
// measure each item's current run — how long it's been continuously on the list.
// We notify twice — empty, then the finished result — so the page renders
// immediately and fills in once the (few-second) sweep lands.
// Shared store the sweep fills, created once: the presence maps (which budgets
// each cocktail / ingredient is chosen at) plus a promise that resolves when the
// sweep finishes — so the chart can stream while the tables wait for the
// completed maps.
const sweepState = (() => {
  let finish;
  const done = new Promise((resolve) => (finish = resolve));
  return {
    cocktailPresence: new Map(), // name -> Set of budgets K where it's chosen
    ingredientPresence: new Map(),
    done,
    finish,
  };
})();
return {sweepState};
},
    inputs: [],
    outputs: ["sweepState"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-11`), expanded: [], variables: []},
  {
    id: 11,
    body: (Generators,glpk,problem,sweepState) => {
// Solve the integer program for every budget 2..129 once. Streams the running
// curve so the chart draws itself in, recording presence into `sweepState` as it
// goes; when the sweep finishes it resolves `sweepState.done`.
const curve = Generators.observe((notify) => {
  let cancelled = false;
  const points = [];
  notify(points);
  (async () => {
    for (let n = 2; n <= 129 && !cancelled; n++) {
      const res = await glpk.solve(
        {
          name: "cocktails",
          objective: problem.objective,
          subjectTo: [
            ...problem.coverage,
            {
              name: "budget",
              vars: problem.ingredients.map((ingredient) => ({
                name: `buy ${ingredient}`,
                coef: 1,
              })),
              bnds: { type: glpk.GLP_UP, ub: n },
            },
          ],
          binaries: problem.binaries,
        },
        { msglev: glpk.GLP_MSG_OFF },
      );
      const covered = problem.recipes.filter(
        ([name]) => res.result.vars[`make ${name}`] > 0.5,
      );
      points.push({ ingredients: n, cocktails: covered.length });
      for (const [name, ingredients] of covered) {
        if (!sweepState.cocktailPresence.has(name)) {
          sweepState.cocktailPresence.set(name, new Set());
        }
        sweepState.cocktailPresence.get(name).add(n);
        for (const ingredient of ingredients) {
          if (!sweepState.ingredientPresence.has(ingredient)) {
            sweepState.ingredientPresence.set(ingredient, new Set());
          }
          sweepState.ingredientPresence.get(ingredient).add(n);
        }
      }
      notify(points.slice());
    }
    if (!cancelled) sweepState.finish();
  })();
  return () => {
    cancelled = true;
  };
});
return {curve};
},
    inputs: ["Generators","glpk","problem","sweepState"],
    outputs: ["curve"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-12`), expanded: [], variables: []},
  {
    id: 12,
    body: (Generators,sweepState) => {
// Hand the tables the presence maps exactly twice: empty up front (so they
// render immediately), then the finished maps once the sweep lands — so they
// snap into run order without re-sorting on every streamed step.
const presence = Generators.observe((notify) => {
  notify({ cocktailPresence: new Map(), ingredientPresence: new Map() });
  sweepState.done.then(() =>
    notify({
      cocktailPresence: sweepState.cocktailPresence,
      ingredientPresence: sweepState.ingredientPresence,
    }),
  );
});
return {presence};
},
    inputs: ["Generators","sweepState"],
    outputs: ["presence"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-13`), expanded: [], variables: []},
  {
    id: 13,
    body: (data,glpk) => {
const problem = (() => {
  const recipes = Object.entries(data);
  const ingredients = [...new Set(recipes.flatMap(([, ings]) => ings))];

  return {
    recipes, // exposed so `solution` can read back which cocktails were chosen
    ingredients, // exposed so `solution` can build the budget row

    // Maximize the number of cocktails we can make.
    objective: {
      direction: glpk.GLP_MAX,
      name: "cocktails",
      vars: recipes.map(([name]) => ({ name: `make ${name}`, coef: 1 })),
    },

    // we want to express that a cocktail can only be made if and only if
    // we hav bought each of its ingredients. one way to exprss this would
    // to have a constraint that of make {cocktail} <= buy {ingredient}
    // for each necessary ingredient. some solvers allow this formulation
    // but glpk doesn't. so we use the equivalent
    // make {cocktail} - buy {ingredient} <= 0
    coverage: recipes.flatMap(([name, ings]) =>
      ings.map((ingredient) => ({
        name: `${name} needs ${ingredient}`,
        vars: [
          { name: `make ${name}`, coef: 1 },
          { name: `buy ${ingredient}`, coef: -1 },
        ],
        bnds: { type: glpk.GLP_UP, ub: 0 },
      })),
    ),

    // Every cocktail and every ingredient is a 0/1 decision.
    binaries: [
      ...recipes.map(([name]) => `make ${name}`),
      ...ingredients.map((ingredient) => `buy ${ingredient}`),
    ],
  };
})();
return {problem};
},
    inputs: ["data","glpk"],
    outputs: ["problem"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-14`), expanded: [], variables: []},
  {
    id: 14,
    body: async () => {
const data = await fetch(
  "/assets/data/cocktail-ingredients-optimizer/ingredients.json",
).then((r) => r.json());
return {data};
},
    inputs: [],
    outputs: ["data"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-15`), expanded: [], variables: []},
  {
    id: 15,
    body: async () => {
const glpk = await (await import("https://esm.sh/glpk.js@4.0.2")).default();
return {glpk};
},
    inputs: [],
    outputs: ["glpk"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

</script>]]></content><author><name>Forest Gregg</name></author><summary type="html"><![CDATA[Stocking your bar is an instance of the Densest k-Subhypergraph problem]]></summary></entry><entry><title type="html">Salesforce is just a really weird web framework</title><link href="https://bunkum.us/2025/12/18/what-is-salesforce.html" rel="alternate" type="text/html" title="Salesforce is just a really weird web framework" /><published>2025-12-18T00:00:00+00:00</published><updated>2025-12-18T00:00:00+00:00</updated><id>https://bunkum.us/2025/12/18/what-is-salesforce</id><content type="html" xml:base="https://bunkum.us/2025/12/18/what-is-salesforce.html"><![CDATA[<p>In the past year, I had to work with Salesforce for the first time as a long-time web developer.</p>

<p>It took me a while to understand what it actually was as a piece of technology, and when I did understand, I was horrified.</p>

<p>Salesforce says that it is a CRM (or Customer Relationship Management software), but that means almost nothing.</p>

<p>Salesforce comes default with some core “objects” like “Account” (a business-like thing) and “Contact” (a person-like thing). There are also default relationships set up between the core objects, so that you can represent that a particular contact works for some business. There are also pages set up so that you can see lists of these objects, see the details of a particular objects, and create new instances of objects.</p>

<p>These default settings presumably go some distance in helping capture the information that a sales team often wants when selling things to businesses.</p>

<p>However, that default functionality is not really what Salesforce is.</p>

<p>Here’s the thing. You can create new types of objects or change the fields that exist on the existing core objects. You can define new relationships between objects. You can, within broad limits, decide what information appears on the listing and detail pages for objects.</p>

<p>If you are web developer, then this is just a variation of MVC. Where objects are Models, pages are Views, and the Controller logic is smeared around different configuration locations.</p>

<p>What that means is that you can build web applications of arbitrary complexity within Salesforce, mainly through their web interface.</p>

<p>A web application of arbitrary complexity that’s not really under version control, and which is not really fully testable, and which is really only works within single, proprietary environment. Yikes!</p>

<p>On the other hand, it’s serverless. Also, most of the security issues are SalesForce’s problems not yours.</p>

<p>Politically, Salesforce has other advantages. For organizations, building the web application in SalesForce often only requires the unit to get authorization for purchasing a single service, whereas as building the application in a normal programming language and deployed to normal servers would require the coordination and collaborating with the IT department. Additionally, the build within Salesforce can sometimes be characterized as OpEx instead of CapEx, which can sometimes be helpful.</p>

<p>So Salesforce is a way of building web applications without fully acknowledging that’s what you are doing. It’s an impressive technical achievement.</p>]]></content><author><name>Forest Gregg</name></author><category term="tech" /><summary type="html"><![CDATA[The horrifying but ultimately admirable truth about what Salesforce really is.]]></summary></entry><entry><title type="html">Chicago Foreign-Born Residents by Country of Origin</title><link href="https://bunkum.us/2025/05/02/chicago-foreign-born-residents-by-country-of-origin.html" rel="alternate" type="text/html" title="Chicago Foreign-Born Residents by Country of Origin" /><published>2025-05-02T00:00:00+00:00</published><updated>2025-05-02T00:00:00+00:00</updated><id>https://bunkum.us/2025/05/02/chicago-foreign-born-residents-by-country-of-origin</id><content type="html" xml:base="https://bunkum.us/2025/05/02/chicago-foreign-born-residents-by-country-of-origin.html"><![CDATA[<p>We can get a sense of international immigration flows by looking at the people living in Chicago who were born in other countries between 2005 and <span data-reactive="0-0">4</span>. Increases in these populations are due to immigration, declines are due to emigration and deaths.</p>
<p><a href="https://censusreporter.org/data/table/?table=B05006&amp;geo_ids=16000US1714000&amp;primary_geo_id=16000US1714000">Fourteen foreign countries each contribute more than 1% of the 2020 Chicago foreign-born population</a>: Mexico, China, Poland, India, Philippines, Guatemala, Ecuador, Nigeria, South Korea, Vietnam, Ukraine, Colombia, Pakistan, and Canada.</p>
<p>The number of Chicago residents born in Mexico has been declining, but they still make up the vast plurality of Chicago’s foreign-born residents.</p>

<div id="cell-1" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2022-11-21-trends-in-tier-scores-at-cps-selective-enrollment-high-schools/cell-1.png" alt="chart" style="max-width:100%"></div>

<p>Here are the trends for the other 13 countries.</p>

<div id="cell-3" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2022-09-22-cps-decline-and-market-share/cell-3.png" alt="chart" style="max-width:100%"></div>

<div id="cell-4" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2022-11-21-trends-in-tier-scores-at-cps-selective-enrollment-high-schools/cell-4.png" alt="chart" style="max-width:100%"></div>

<div id="cell-5" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2022-09-22-cps-decline-and-market-share/cell-5.png" alt="chart" style="max-width:100%"></div>

<div id="cell-6" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2024-11-26-chicago-policing-force-size-and-budgets/cell-6.png" alt="chart" style="max-width:100%"></div>

<div id="cell-7" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2024-01-01-michigan-property-tax-simulator/cell-7.png" alt="chart" style="max-width:100%"></div>

<div id="cell-8" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2023-06-04-chicago-population-pyramids/cell-8.png" alt="chart" style="max-width:100%"></div>

<div id="cell-9" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2023-11-01-districting-for-the-chicago-public-schools-elected-board/cell-9.png" alt="chart" style="max-width:100%"></div>

<div id="cell-10" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2024-01-01-michigan-property-tax-simulator/cell-10.png" alt="chart" style="max-width:100%"></div>

<div id="cell-11" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2025-04-01-dte-outages/cell-11.png" alt="chart" style="max-width:100%"></div>

<div id="cell-12" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2023-11-01-districting-for-the-chicago-public-schools-elected-board/cell-12.png" alt="chart" style="max-width:100%"></div>

<script type="module">
import {define, main, Plot, Inputs, d3} from "/assets/js/reactive-runtime.js";
// Register Plot, Inputs, and d3 as reactive variables so cells that
// reference them resolve through the dependency graph (not runtime builtins).
main.variable().define("Plot", [], () => Plot);
main.variable().define("Inputs", [], () => Inputs);
main.variable().define("d3", [], () => d3);
// Hydrate an inline ${…}: bind its reactive value to its placeholder span,
// updating only that span — never re-render the surrounding (static) prose.
function hydrate(ref, inputs, body) {
  const el = document.querySelector(`[data-reactive="${ref}"]`);
  if (!el) return;
  main.variable({
    pending() {},
    rejected(error) { console.error(error); },
    fulfilled(value) {
      el.replaceChildren(value instanceof Node ? value : document.createTextNode(value == null ? "" : String(value)));
    }
  }).define(null, inputs, body);
}
hydrate("0-0", ["last_year"], (last_year) => {
return (
last_year
)
});
define(
  {root: document.getElementById(`cell-1`), expanded: [], variables: []},
  {
    id: 1,
    body: (display,Plot,data) => {
display(
  Plot.plot({
    width: 200,
    height: 200,
    y: {
      grid: true,
      tickFormat: "2s",
      nice: true,
      label: "people",
    },
    marks: [
      Plot.frame(),
      Plot.line(data, {
        filter: (d) => d.country === "Mexico",
        x: (d) => new Date(d.year.toString()),
        y: "value",
        stroke: "country",
        title: "country",
      }),
    ],
  }),
);
},
    inputs: ["display","Plot","data"],
    outputs: [],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-3`), expanded: [], variables: []},
  {
    id: 3,
    body: (display,Plot,without_mexico,fxy,last_year) => {
display(
  Plot.plot({
    y: {
      grid: true,
      tickFormat: "2s",
      nice: true,
      label: "people",
    },
    fx: { axis: null },
    fy: { axis: null },
    marks: [
      Plot.frame(),
      Plot.line(without_mexico, {
        x: (d) => new Date(d.year.toString()),
        y: "value",
        stroke: "country",
        title: "country",
        fx: (d) => fxy(d.country)[0],
        fy: (d) => fxy(d.country)[1],
        tip: true,
      }),
      Plot.text(
        without_mexico,
        Plot.selectFirst({
          text: (d) =>
            d.country === "China, excluding Hong Kong and Taiwan"
              ? "China"
              : d.country === "Korea"
                ? "South Korea"
                : d.country,
          x: new Date(last_year.toString()),
          y: 55000,
          dx: -4,
          textAnchor: "end",
          fontWeight: "bold",
          fx: (d) => fxy(d.country)[0],
          fy: (d) => fxy(d.country)[1],
        }),
      ),
    ],
  }),
);
},
    inputs: ["display","Plot","without_mexico","fxy","last_year"],
    outputs: [],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-4`), expanded: [], variables: []},
  {
    id: 4,
    body: (d3) => {
// Inlined from @fgregg/census-api-helper-functions — live ACS pulls from
// census-api.bunkum.us (proxy that fixes CORS for the Census API).
const fetchCensusGroup = async function (
  year,
  base_var,
  place_fips = "14000",
  state = "17",
  expand_var = false,
) {
  const response = await fetch(
    `https://census-api.bunkum.us/data/${year}/acs/acs1?get=NAME,group(${base_var})&for=place:${place_fips}&in=state:${state}`,
  );
  const data = await response.json();
  const zipped = d3.zip(...data);
  const geography = zipped.find((d) => d[0] === "NAME")[1];
  const vars = zipped
    .filter((d) => d[0] !== "NAME")
    .map(([variable, value]) => ({
      year,
      geography,
      variable,
      value: Number(value),
    }));
  if (expand_var) {
    const var_response = await fetch(
      `https://census-api.bunkum.us/data/${year}/acs/acs1/groups/${base_var}.json`,
    );
    const var_data = await var_response.json();
    return vars.map((d) => ({
      ...d,
      variable_definition: var_data.variables[d.variable],
    }));
  }
  return vars;
};
return {fetchCensusGroup};
},
    inputs: ["d3"],
    outputs: ["fetchCensusGroup"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-5`), expanded: [], variables: []},
  {
    id: 5,
    body: (d3,fetchCensusGroup) => {
const censusChicagoGroupYears = async (
  base_var,
  start = 2005,
  stop = 2021,
  expand_var = false,
) => {
  const variables_promises = d3
    .range(start, stop + 1)
    .filter((d) => d !== 2020)
    .map((year) => fetchCensusGroup(year, base_var, "14000", "17", expand_var));
  return (await Promise.all(variables_promises)).flat();
};
return {censusChicagoGroupYears};
},
    inputs: ["d3","fetchCensusGroup"],
    outputs: ["censusChicagoGroupYears"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-6`), expanded: [], variables: []},
  {
    id: 6,
    body: (censusChicagoGroupYears,last_year) => {
const raw_data = censusChicagoGroupYears("B05006", 2005, last_year, true);
return {raw_data};
},
    inputs: ["censusChicagoGroupYears","last_year"],
    outputs: ["raw_data"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-7`), expanded: [], variables: []},
  {
    id: 7,
    body: (raw_data,one_percenters) => {
const data = raw_data
  .map((d) => ({
    ...d,
    country: one_percenters.find((e) =>
      d.variable_definition?.label.endsWith(e),
    ),
  }))
  .filter((d) => d.country)
  .filter((d) => d.variable.endsWith("E"));
return {data};
},
    inputs: ["raw_data","one_percenters"],
    outputs: ["data"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-8`), expanded: [], variables: []},
  {
    id: 8,
    body: (data) => {
const without_mexico = data.filter((d) => d.country !== "Mexico");
return {without_mexico};
},
    inputs: ["data"],
    outputs: ["without_mexico"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-9`), expanded: [], variables: []},
  {
    id: 9,
    body: (raw_data,last_year,total) => {
const one_percenters = raw_data
  .filter(
    (d) =>
      d.year === last_year &&
      d.value / total >= 0.01 &&
      d.variable.endsWith("E"),
  )
  .map(
    (d) =>
      d.variable_definition?.label.split("!!")[
        d.variable_definition?.label.split("!!").length - 1
      ],
  )
  .filter((d) => d && !d?.endsWith(":"));
return {one_percenters};
},
    inputs: ["raw_data","last_year","total"],
    outputs: ["one_percenters"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-10`), expanded: [], variables: []},
  {
    id: 10,
    body: (raw_data,last_year) => {
const total = raw_data.find(
  (d) =>
    d.variable_definition?.label === "Estimate!!Total:" && d.year === last_year,
).value;
return {total};
},
    inputs: ["raw_data","last_year"],
    outputs: ["total"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-11`), expanded: [], variables: []},
  {
    id: 11,
    body: () => {
const last_year = 2024;
return {last_year};
},
    inputs: [],
    outputs: ["last_year"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-12`), expanded: [], variables: []},
  {
    id: 12,
    body: (d3,without_mexico) => {
// The original used a patched Plot from @fil/plot-early-bird that adds a facet
// "columns" option. Using the built-in Plot instead and wrapping facets by
// hand: order countries by their latest value (matching the original sort) and
// lay them out in a 4-column grid via computed fx/fy.
const facet_cols = 4;
const country_order = Array.from(
  d3.rollup(
    without_mexico,
    (v) => d3.greatest(v, (d) => d.year)?.value ?? 0,
    (d) => d.country,
  ),
)
  .sort((a, b) => b[1] - a[1])
  .map(([country]) => country);
const fxy = (country) => {
  const i = country_order.indexOf(country);
  return [i % facet_cols, Math.floor(i / facet_cols)];
};
return {facet_cols,country_order,fxy};
},
    inputs: ["d3","without_mexico"],
    outputs: ["facet_cols","country_order","fxy"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

</script>]]></content><author><name>Forest Gregg</name></author><summary type="html"><![CDATA[Chicago foreign-born residents by country of origin over time, from live ACS data, faceted by country.]]></summary></entry><entry><title type="html">DTE Outages</title><link href="https://bunkum.us/2025/04/01/dte-outages.html" rel="alternate" type="text/html" title="DTE Outages" /><published>2025-04-01T00:00:00+00:00</published><updated>2025-04-01T00:00:00+00:00</updated><id>https://bunkum.us/2025/04/01/dte-outages</id><content type="html" xml:base="https://bunkum.us/2025/04/01/dte-outages.html"><![CDATA[<p>Consumer Energy outages included for comparison.</p>
<p>Last updated: <span data-reactive="0-0">4</span>.</p>

<div id="cell-1" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2022-11-21-trends-in-tier-scores-at-cps-selective-enrollment-high-schools/cell-1.png" alt="chart" style="max-width:100%"></div>

<div id="cell-2" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2023-06-04-chicago-population-pyramids/cell-2.png" alt="chart" style="max-width:100%"></div>

<div id="cell-3" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2022-09-22-cps-decline-and-market-share/cell-3.png" alt="chart" style="max-width:100%"></div>

<div id="cell-4" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2022-11-21-trends-in-tier-scores-at-cps-selective-enrollment-high-schools/cell-4.png" alt="chart" style="max-width:100%"></div>

<div id="cell-5" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2022-09-22-cps-decline-and-market-share/cell-5.png" alt="chart" style="max-width:100%"></div>

<div id="cell-6" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2024-11-26-chicago-policing-force-size-and-budgets/cell-6.png" alt="chart" style="max-width:100%"></div>

<div id="cell-7" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2024-01-01-michigan-property-tax-simulator/cell-7.png" alt="chart" style="max-width:100%"></div>

<div id="cell-8" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2023-06-04-chicago-population-pyramids/cell-8.png" alt="chart" style="max-width:100%"></div>

<div id="cell-9" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2023-11-01-districting-for-the-chicago-public-schools-elected-board/cell-9.png" alt="chart" style="max-width:100%"></div>

<h2 id="historical-data" tabindex="-1">Historical Data <a class="header-anchor" href="#historical-data" aria-hidden="true">#</a></h2>
<p>Download the raw, historical data for</p>
<ul>
<li><a href="https://github.com/fgregg/dte-outages/archive/refs/heads/main.zip">DTE</a></li>
<li><a href="https://github.com/fgregg/consumer-energy-outages/archive/refs/heads/main.zip">Consumer Energy</a></li>
</ul>

<div id="cell-11" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2025-04-01-dte-outages/cell-11.png" alt="chart" style="max-width:100%"></div>

<div id="cell-12" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2023-11-01-districting-for-the-chicago-public-schools-elected-board/cell-12.png" alt="chart" style="max-width:100%"></div>

<div id="cell-13" class="reactive-cell"></div>

<div id="cell-14" class="reactive-cell"></div>

<div id="cell-15" class="reactive-cell"></div>

<div id="cell-16" class="reactive-cell"></div>

<div id="cell-17" class="reactive-cell"></div>

<div id="cell-18" class="reactive-cell"></div>

<div id="cell-19" class="reactive-cell"></div>

<div id="cell-20" class="reactive-cell"></div>

<div id="cell-21" class="reactive-cell"></div>

<div id="cell-22" class="reactive-cell"></div>

<div id="cell-23" class="reactive-cell"></div>

<div id="cell-24" class="reactive-cell"></div>

<div id="cell-25" class="reactive-cell"></div>

<div id="cell-26" class="reactive-cell"></div>

<div id="cell-27" class="reactive-cell"></div>

<div id="cell-28" class="reactive-cell"></div>

<div id="cell-29" class="reactive-cell"></div>

<div id="cell-30" class="reactive-cell"></div>

<div id="cell-31" class="reactive-cell"></div>

<div id="cell-32" class="reactive-cell"></div>

<div id="cell-33" class="reactive-cell"></div>

<div id="cell-34" class="reactive-cell"></div>

<div id="cell-35" class="reactive-cell"></div>

<div id="cell-36" class="reactive-cell"></div>

<div id="cell-37" class="reactive-cell"></div>

<div id="cell-38" class="reactive-cell"></div>

<div id="cell-39" class="reactive-cell"></div>

<div id="cell-40" class="reactive-cell"></div>

<div id="cell-41" class="reactive-cell"></div>

<div id="cell-42" class="reactive-cell"></div>

<div id="cell-43" class="reactive-cell"></div>

<div id="cell-44" class="reactive-cell"></div>

<div id="cell-45" class="reactive-cell"></div>

<div id="cell-46" class="reactive-cell"></div>

<div id="cell-47" class="reactive-cell"></div>

<div id="cell-48" class="reactive-cell"></div>

<div id="cell-49" class="reactive-cell"></div>

<div id="cell-50" class="reactive-cell"></div>

<div id="cell-51" class="reactive-cell"></div>

<div id="cell-52" class="reactive-cell"></div>

<script type="module">
import {define, main, Plot, Inputs, d3} from "/assets/js/reactive-runtime.js";
// Register Plot, Inputs, and d3 as reactive variables so cells that
// reference them resolve through the dependency graph (not runtime builtins).
main.variable().define("Plot", [], () => Plot);
main.variable().define("Inputs", [], () => Inputs);
main.variable().define("d3", [], () => d3);
// Hydrate an inline ${…}: bind its reactive value to its placeholder span,
// updating only that span — never re-render the surrounding (static) prose.
function hydrate(ref, inputs, body) {
  const el = document.querySelector(`[data-reactive="${ref}"]`);
  if (!el) return;
  main.variable({
    pending() {},
    rejected(error) { console.error(error); },
    fulfilled(value) {
      el.replaceChildren(value instanceof Node ? value : document.createTextNode(value == null ? "" : String(value)));
    }
  }).define(null, inputs, body);
}
hydrate("0-0", ["last_checked"], (last_checked) => {
return (
last_checked.toLocaleString()
)
});
define(
  {root: document.getElementById(`cell-1`), expanded: [], variables: []},
  {
    id: 1,
    body: (display,Plot,boundary,counties,show_storm_tracks,d3,storm_tracks,areas,points,rewind,consumer_energy_outages) => {
display(
Plot.plot({
  title: "DTE and Consumer Energy Outages",
  projection: { type: "mercator", domain: boundary },
  color: {
    type: "categorical",
    domain: ["Consumer Energy", "DTE"],
    legend: true
  },
  marks: [
    Plot.geo(counties, { strokeOpacity: 0.25 }),
    Plot.geo(boundary),
    show_storm_tracks
      ? d3
          .groups(storm_tracks, (d) => `${d.WSR_ID}:${d.CELL_ID}:${d.run_id}`)
          .map(([z, track]) =>
            Plot.line(track, {
              x: "LON",
              y: "LAT",
              stroke: "blue",
              strokeWidth: (d) => d.MAX_REFLECT - 45,
              render: function (
                index,
                scales,
                values,
                dimensions,
                context,
                next
              ) {
                const g = next(index, scales, values, dimensions, context);
                g.style.opacity = 0.05;
                return g;
              }
            })
          )
      : null,
    Plot.geo(areas, { fill: (d) => "DTE" }),
    Plot.geo(points, { r: 0.3, stroke: (d) => "DTE" }),
    Plot.geo(rewind(consumer_energy_outages), {
      fill: (d) => "Consumer Energy"
    }),
    Plot.tip(
      areas.features,
      Plot.pointer(
        Plot.geoCentroid({
          title: (d) =>
            `Affected customers: ${d.properties.desc.cust_a.val.toLocaleString()}`
        })
      )
    ),
    Plot.tip(
      rewind(consumer_energy_outages).features,
      Plot.pointer(
        Plot.geoCentroid({
          title: (d) =>
            `Affected customers: ${d.properties.CUSTOMER_COUNT.toLocaleString()}`
        })
      )
    )
  ]
})
);
},
    inputs: ["display","Plot","boundary","counties","show_storm_tracks","d3","storm_tracks","areas","points","rewind","consumer_energy_outages"],
    outputs: [],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-2`), expanded: [], variables: []},
  {
    id: 2,
    body: (view,Inputs) => {
const target_date = view(Inputs.datetime({
  label: "Date and time of outages (defaults to present)",
  value: new Date()
}));
return {target_date};
},
    inputs: ["view","Inputs"],
    outputs: ["target_date"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-3`), expanded: [], variables: []},
  {
    id: 3,
    body: (view,Inputs) => {
const show_storm_tracks = view(Inputs.toggle({
  label: "Show Storm Tracks?",
  value: false
}));
return {show_storm_tracks};
},
    inputs: ["view","Inputs"],
    outputs: ["show_storm_tracks"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-4`), expanded: [], variables: []},
  {
    id: 4,
    body: (view,Inputs) => {
const storm_date = view(
  (() => {
    const two_days_ago = new Date();
    two_days_ago.setDate(two_days_ago.getDate() - 2);
    return Inputs.date({
      label: "Storm Date",
      value: two_days_ago,
      max: two_days_ago,
    });
  })(),
);
return {storm_date};
},
    inputs: ["view","Inputs"],
    outputs: ["storm_date"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-5`), expanded: [], variables: []},
  {
    id: 5,
    body: (display,Plot,day_sum) => {
display(
Plot.plot({
  title: "DTE Estimated Restoration Day for Current Outages",
  y: { grid: true, label: "Affected Customers" },
  marginLeft: 50,
  marks: [
    Plot.barY(day_sum, {
      x: (d) =>
        isNaN(d.date)
          ? "no estimate"
          : d.date.toLocaleString(undefined, {
              month: "short",
              day: "numeric"
            }),
      y: "sum",
      tip: true
    }),
    Plot.ruleY([0])
  ]
})
);
},
    inputs: ["display","Plot","day_sum"],
    outputs: [],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-6`), expanded: [], variables: []},
  {
    id: 6,
    body: (display,Plot,d3,outages) => {
display(
Plot.plot({
  clip: true,
  title: "DTE Ages of Current Outages",
  marginLeft: 50,
  y: { grid: true, label: "customers affected" },
  x: {
    domain: [
      0,
      (new Date() -
        d3.quantile(
          outages.map((d) => d.desc.start_time),
          0.0025
        )) /
        (1000 * 60 * 60)
    ],
    label: "hours since start of outage",
    reverse: true
  },
  color: { legend: true, scheme: "accent" },
  marks: [
    Plot.rectY(
      outages,
      Plot.binX(
        { y: "sum" },
        {
          tip: true,
          x: (d) => (new Date() - d.desc.start_time) / (1000 * 60 * 60),
          y: (d) => d.desc.cust_a.val,
          interval: 1,
          fill: (d) =>
            isNaN(d.desc.estimate_fixed)
              ? "no estimate"
              : `Estimate fixed by ${d.desc.estimate_fixed.toLocaleString(
                  undefined,
                  {
                    month: "short",
                    day: "numeric"
                  }
                )}`
        }
      )
    ),
    Plot.ruleY([0])
  ]
})
);
},
    inputs: ["display","Plot","d3","outages"],
    outputs: [],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-7`), expanded: [], variables: []},
  {
    id: 7,
    body: (display,Plot,d3,outages) => {
display(
Plot.plot({
  clip: true,
  title: "DTE Ages of Current Outages, Cumulative",
  marginLeft: 50,
  y: { grid: true, label: "customers affected" },
  x: {
    domain: [
      0,
      (new Date() -
        d3.quantile(
          outages.map((d) => d.desc.start_time),
          0.0025
        )) /
        (1000 * 60 * 60)
    ],
    label: "hours since start of outage",
    reverse: true
  },
  color: { legend: true, scheme: "accent" },
  marks: [
    Plot.rectY(
      outages,
      Plot.binX(
        { y: "sum" },
        {
          tip: true,
          x: (d) => (new Date() - d.desc.start_time) / (1000 * 60 * 60),
          y: (d) => d.desc.cust_a.val,
          interval: 1,
          fill: (d) =>
            isNaN(d.desc.estimate_fixed)
              ? "no estimate"
              : `Estimate fixed by ${d.desc.estimate_fixed.toLocaleString(
                  undefined,
                  {
                    month: "short",
                    day: "numeric"
                  }
                )}`,
          cumulative: -1
        }
      )
    ),
    Plot.ruleY([0])
  ]
})
);
},
    inputs: ["display","Plot","d3","outages"],
    outputs: [],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-8`), expanded: [], variables: []},
  {
    id: 8,
    body: (display,Plot,boundary,house_districts,house_district_outages) => {
display(
Plot.plot({
  title: "Affected customers by State Representative",
  projection: { type: "mercator", domain: boundary },
  color: {
    scheme: "oranges",
    type: "linear",
    legend: true,
    label: "Affected customers"
  },
  marks: [
    Plot.geo(house_districts, { strokeOpacity: 0.1 }),
    Plot.geo(house_district_outages, {
      fill: (d) => d.properties.n_affected_customers,
      stroke: "black",
      strokeOpacity: 0.25
    }),
    Plot.tip(
      house_district_outages.features,
      Plot.pointer(
        Plot.geoCentroid({
          title: (d) =>
            `House district ${
              d.properties.LABEL
            }\nAffected customers: ${d.properties.n_affected_customers.toLocaleString()}`
        })
      )
    )
  ]
})
);
},
    inputs: ["display","Plot","boundary","house_districts","house_district_outages"],
    outputs: [],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-9`), expanded: [], variables: []},
  {
    id: 9,
    body: (display,Plot,boundary,rewind,senate_districts,senate_district_outages) => {
display(
Plot.plot({
  title: "Affected Customers by State Senator",
  projection: { type: "mercator", domain: boundary },
  color: {
    scheme: "oranges",
    type: "linear",
    legend: true,
    label: "Affected customers"
  },
  marks: [
    Plot.geo(rewind(senate_districts), { strokeOpacity: 0.1 }),
    Plot.geo(senate_district_outages, {
      fill: (d) => d.properties.n_affected_customers,
      stroke: "black",
      strokeOpacity: 0.25,
      tip: true
    }),
    Plot.tip(
      senate_district_outages.features,
      Plot.pointer(
        Plot.geoCentroid({
          title: (d) =>
            `Senate district ${
              d.properties.LABEL
            }\nAffected customers: ${d.properties.n_affected_customers.toLocaleString()}`
        })
      )
    )
  ]
})
);
},
    inputs: ["display","Plot","boundary","rewind","senate_districts","senate_district_outages"],
    outputs: [],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-11`), expanded: [], variables: []},
  {
    id: 11,
    body: (display,Plot,dte_outage_history,ce_outage_history) => {
display(
Plot.plot({
  title: "Number of Affected Customers over Time",
  color: {
    type: "categorical",
    domain: ["Consumer Energy", "DTE"],
    legend: true
  },
  marginLeft: 60,
  x: { type: "time" },
  y: { grid: true },
  marks: [
    Plot.line(dte_outage_history, {
      x: "timestamp",
      y: "affected customers",
      stroke: (d) => "DTE"
    }),
    Plot.line(ce_outage_history, {
      x: "timestamp",
      y: "affected customers",
      stroke: (d) => "Consumer Energy"
    }),
    Plot.ruleY([0]),
    Plot.tip(
      dte_outage_history,
      Plot.pointer({
        x: "timestamp",
        y: "affected customers",
        title: (d) =>
          `time: ${d.timestamp.toLocaleString()}\ncustomers affected ${d[
            "affected customers"
          ].toLocaleString()}`
      })
    )
  ]
})
);
},
    inputs: ["display","Plot","dte_outage_history","ce_outage_history"],
    outputs: [],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-12`), expanded: [], variables: []},
  {
    id: 12,
    body: (display,Plot,dte_outage_history,ce_outage_history) => {
display(
Plot.plot({
  title: "Number of Outages over Time",
  color: {
    type: "categorical",
    domain: ["Consumer Energy", "DTE"],
    legend: true
  },
  marginLeft: 60,
  x: { type: "time" },
  y: { grid: true },
  marks: [
    Plot.line(dte_outage_history, {
      x: "timestamp",
      y: "outages",
      stroke: (d) => "DTE"
    }),
    Plot.line(ce_outage_history, {
      x: "timestamp",
      y: "outages",
      stroke: (d) => "Consumer Energy"
    }),
    Plot.ruleY([0]),
    Plot.tip(
      dte_outage_history,
      Plot.pointer({
        x: "timestamp",
        y: "outages",
        title: (d) =>
          `time: ${d.timestamp.toLocaleString()}\noutages ${d.outages.toLocaleString()}`
      })
    )
  ]
})
);
},
    inputs: ["display","Plot","dte_outage_history","ce_outage_history"],
    outputs: [],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-13`), expanded: [], variables: []},
  {
    id: 13,
    body: (outages_per_area,dte_house_districts,indexed_outages) => {
const house_district_outages = outages_per_area(dte_house_districts, indexed_outages);
return {house_district_outages};
},
    inputs: ["outages_per_area","dte_house_districts","indexed_outages"],
    outputs: ["house_district_outages"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-14`), expanded: [], variables: []},
  {
    id: 14,
    body: (outages_per_area,dte_senate_districts,indexed_outages) => {
const senate_district_outages = outages_per_area(
  dte_senate_districts,
  indexed_outages
);
return {senate_district_outages};
},
    inputs: ["outages_per_area","dte_senate_districts","indexed_outages"],
    outputs: ["senate_district_outages"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-15`), expanded: [], variables: []},
  {
    id: 15,
    body: (d3,outages) => {
const day_sum = d3
  .flatRollup(
    outages,
    (v) => d3.sum(v.map((d) => d.desc.cust_a.val)),
    (d) => d.desc.estimate_fixed
  )
  .map(([date, sum]) => ({ date, sum }));
return {day_sum};
},
    inputs: ["d3","outages"],
    outputs: ["day_sum"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-16`), expanded: [], variables: []},
  {
    id: 16,
    body: (getMostRecentlyCommittedFile) => {
const consumer_energy_outage_paths = getMostRecentlyCommittedFile(
  "fgregg",
  "consumer-energy-outages",
  "ce_",
  "data"
);
return {consumer_energy_outage_paths};
},
    inputs: ["getMostRecentlyCommittedFile"],
    outputs: ["consumer_energy_outage_paths"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-17`), expanded: [], variables: []},
  {
    id: 17,
    body: (consumer_energy_outage_paths,extractDateFromFilename,target_date) => {
const consumer_energy_outage_path = consumer_energy_outage_paths.sort(
  (a, b) =>
    Math.abs(extractDateFromFilename(a, "ce_") - target_date) -
    Math.abs(extractDateFromFilename(b, "ce_") - target_date)
)[0];
return {consumer_energy_outage_path};
},
    inputs: ["consumer_energy_outage_paths","extractDateFromFilename","target_date"],
    outputs: ["consumer_energy_outage_path"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-18`), expanded: [], variables: []},
  {
    id: 18,
    body: (consumer_energy_outage_path) => {
const consumer_energy_outages = (async () => {
  const response = await fetch(
    `https://corsproxy.bunkum.us/corsproxy/?apiurl=https://raw.githubusercontent.com/fgregg/consumer-energy-outages/main/data/${consumer_energy_outage_path}`
  );
  const data = await response.json();
  return data;
})();
return {consumer_energy_outages};
},
    inputs: ["consumer_energy_outage_path"],
    outputs: ["consumer_energy_outages"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-19`), expanded: [], variables: []},
  {
    id: 19,
    body: (turf,service_area_data,polyline) => {
const boundary = turf.featureCollection([
  turf.polygon(
    service_area_data.file_data[0].geom.a.map((pl) =>
      polyline.decode(pl).map((pt) => pt.reverse())
    )
  )
]);
return {boundary};
},
    inputs: ["turf","service_area_data","polyline"],
    outputs: ["boundary"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-20`), expanded: [], variables: []},
  {
    id: 20,
    body: () => {
const service_area_data = (async () => {
  const response = await fetch(
    "https://kubra.io/regions/106f1194-a523-4e92-a093-ac535a46d58a/78f5f8aa-a4e2-45c5-b1e3-b335bdbd84b2/serviceareas.json"
  );
  const data = await response.json();
  return data;
})();
return {service_area_data};
},
    inputs: [],
    outputs: ["service_area_data"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-21`), expanded: [], variables: []},
  {
    id: 21,
    body: (turf,outages) => {
const areas = turf.featureCollection(
  outages
    .filter((d) => d.geom.a)
    .map(({ geom, ...d }) => turf.polygon(geom.a, d))
    .filter((d) => turf.area(d) < 108172795)
);
return {areas};
},
    inputs: ["turf","outages"],
    outputs: ["areas"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-22`), expanded: [], variables: []},
  {
    id: 22,
    body: (turf,outages) => {
const points = turf.featureCollection(
  outages
    .filter((d) => !d.geom.a)
    .map(({ geom, ...d }) => turf.point(geom.p, d))
);
return {points};
},
    inputs: ["turf","outages"],
    outputs: ["points"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-23`), expanded: [], variables: []},
  {
    id: 23,
    body: async () => {
const polyline = (await import("https://esm.sh/@mapbox/polyline@1.2.0")).default;
return {polyline};
},
    inputs: [],
    outputs: ["polyline"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-24`), expanded: [], variables: []},
  {
    id: 24,
    body: async () => {
const turf = await import("https://esm.sh/@turf/turf@6.5.0");
return {turf};
},
    inputs: [],
    outputs: ["turf"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-25`), expanded: [], variables: []},
  {
    id: 25,
    body: async () => {
const topojson = await import("https://esm.sh/topojson-client@3");
return {topojson};
},
    inputs: [],
    outputs: ["topojson"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-26`), expanded: [], variables: []},
  {
    id: 26,
    body: async (topojson) => {
// Was imported from @observablehq/plot-mapping; build it from the us-atlas CDN.
const counties = await (async () => {
  const us = await fetch(
    "https://cdn.jsdelivr.net/npm/us-atlas@3/counties-10m.json",
  ).then((r) => r.json());
  return topojson.feature(us, us.objects.counties);
})();
return {counties};
},
    inputs: ["topojson"],
    outputs: ["counties"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-27`), expanded: [], variables: []},
  {
    id: 27,
    body: () => {
// Function to get the most recently committed file in a public GitHub repository
async function getMostRecentlyCommittedFile(
  username,
  repository,
  filePattern,
  path
) {
  try {
    // Construct the GitHub API URL to list commits in the repository
    const commitsURL = `https://api.github.com/repos/${username}/${repository}/commits`;

    // Fetch the commits from the GitHub API
    const response = await fetch(commitsURL);

    if (!response.ok) {
      throw new Error(`Failed to fetch commits. Status: ${response.status}`);
    }

    const commits = await response.json();

    // Find the latest commit
    const latestCommit = commits[0];

    // Get the tree URL of the latest commit
    const treeURL = latestCommit.commit.tree.url;

    // Fetch the tree details from the GitHub API
    const treeResponse = await fetch(treeURL);

    if (!treeResponse.ok) {
      throw new Error(
        `Failed to fetch tree details. Status: ${treeResponse.status}`
      );
    }

    let treeData = await treeResponse.json();

    if (path) {
      const response = await fetch(
        treeData.tree.find((file) => file.path === path).url
      );
      treeData = await response.json();
    }
    console.log(treeData);

    // Find the file that matches the pattern
    return treeData.tree
      .filter((file) => file.path.includes(filePattern))
      .map((d) => d.path);
  } catch (error) {
    console.error("Error:", error.message);
    return null;
  }
}
return {getMostRecentlyCommittedFile};
},
    inputs: [],
    outputs: ["getMostRecentlyCommittedFile"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-28`), expanded: [], variables: []},
  {
    id: 28,
    body: (geoRewindProjection,geoRewindFeature) => {
// Inlined from @fil/rewind (fixes GeoJSON winding for d3/Plot projections).
function rewind(duck, simple) {
  return duck?.stream
    ? geoRewindProjection(duck, simple)
    : duck?.type
      ? geoRewindFeature(duck, simple)
      : Array.isArray(duck)
        ? Array.from(duck, (d) => rewind(d, simple))
        : duck;
}
return {rewind};
},
    inputs: ["geoRewindProjection","geoRewindFeature"],
    outputs: ["rewind"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-29`), expanded: [], variables: []},
  {
    id: 29,
    body: (geoProjectSimple,geoRewindStream) => {
const geoRewindFeature = (feature, simple) =>
  geoProjectSimple(feature, geoRewindStream(simple));
return {geoRewindFeature};
},
    inputs: ["geoProjectSimple","geoRewindStream"],
    outputs: ["geoRewindFeature"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-30`), expanded: [], variables: []},
  {
    id: 30,
    body: (geoRewindStream) => {
const geoRewindProjection = (projection, simple) => {
  const { stream: normalize } = geoRewindStream(simple);
  return { stream: (s) => normalize(projection.stream(s)) };
};
return {geoRewindProjection};
},
    inputs: ["geoRewindStream"],
    outputs: ["geoRewindProjection"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-31`), expanded: [], variables: []},
  {
    id: 31,
    body: (d3) => {
function geoRewindStream(simple = true) {
  const { geoContains, geoArea } = d3;
  let ring, polygon;
  return d3.geoTransform({
    polygonStart() { this.stream.polygonStart(); polygon = []; },
    lineStart() { if (polygon) polygon.push((ring = [])); else this.stream.lineStart(); },
    lineEnd() { if (!polygon) this.stream.lineEnd(); },
    point(x, y) { if (polygon) ring.push([x, y]); else this.stream.point(x, y); },
    polygonEnd() {
      for (let [i, ring] of polygon.entries()) {
        ring.push(ring[0].slice());
        if (
          i
            ? !geoContains({ type: "Polygon", coordinates: [ring] }, polygon[0][0])
            : polygon[1]
              ? !geoContains({ type: "Polygon", coordinates: [ring] }, polygon[1][0])
              : simple && geoArea({ type: "Polygon", coordinates: [ring] }) > 2 * Math.PI
        ) ring.reverse();
        this.stream.lineStart();
        ring.pop();
        for (const [x, y] of ring) this.stream.point(x, y);
        this.stream.lineEnd();
      }
      this.stream.polygonEnd();
      polygon = null;
    },
  });
}
return {geoRewindStream};
},
    inputs: ["d3"],
    outputs: ["geoRewindStream"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-32`), expanded: [], variables: []},
  {
    id: 32,
    body: (d3) => {
const geoProjectSimple = (() => {
  const { geoStream } = d3;
  function projectFeatureCollection(o, stream) {
    return { ...o, features: o.features.map((f) => projectFeature(f, stream)) };
  }
  function projectFeature(o, stream) {
    return { ...o, geometry: projectGeometry(o.geometry, stream) };
  }
  function projectGeometryCollection(o, stream) {
    return { ...o, geometries: o.geometries.map((g) => projectGeometry(g, stream)) };
  }
  function projectGeometry(o, stream) {
    return !o ? null
      : o.type === "GeometryCollection" ? projectGeometryCollection(o, stream)
      : o.type === "Polygon" || o.type === "MultiPolygon" ? projectPolygons(o, stream)
      : o;
  }
  function projectPolygons(o, stream) {
    let coordinates = [];
    let polygon, line;
    geoStream(o, stream({
      polygonStart() { coordinates.push((polygon = [])); },
      polygonEnd() {},
      lineStart() { polygon.push((line = [])); },
      lineEnd() { line.push(line[0].slice()); },
      point(x, y) { line.push([x, y]); },
    }));
    if (o.type === "Polygon") coordinates = coordinates[0];
    return { ...o, coordinates, rewind: true };
  }
  return function (object, projection) {
    const stream = projection.stream;
    if (!stream) throw new Error("invalid projection");
    const project =
      object && object.type === "Feature" ? projectFeature
      : object && object.type === "FeatureCollection" ? projectFeatureCollection
      : projectGeometry;
    return project(object, stream);
  };
})();
return {geoProjectSimple};
},
    inputs: ["d3"],
    outputs: ["geoProjectSimple"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-33`), expanded: [], variables: []},
  {
    id: 33,
    body: (getMostRecentlyCommittedFile) => {
const outage_paths = getMostRecentlyCommittedFile("fgregg", "dte-outages", "dte_");
return {outage_paths};
},
    inputs: ["getMostRecentlyCommittedFile"],
    outputs: ["outage_paths"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-34`), expanded: [], variables: []},
  {
    id: 34,
    body: (outage_paths,extractDateFromFilename,target_date) => {
const scraped_path = outage_paths.sort(
  (a, b) =>
    Math.abs(extractDateFromFilename(a, "dte_") - target_date) -
    Math.abs(extractDateFromFilename(b, "dte_") - target_date)
)[0];
return {scraped_path};
},
    inputs: ["outage_paths","extractDateFromFilename","target_date"],
    outputs: ["scraped_path"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-35`), expanded: [], variables: []},
  {
    id: 35,
    body: (display,d3,outages) => {
display(
d3.min(outages.map((d) => new Date(d.desc.start_time)))
);
},
    inputs: ["display","d3","outages"],
    outputs: [],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-36`), expanded: [], variables: []},
  {
    id: 36,
    body: (scraped_path,polyline) => {
const outages = (async () => {
  const datePattern =
    /^(Sunday|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday), (January|February|March|April|May|June|July|August|September|October|November|December) \d{1,2}$/;
  const response = await fetch(
    `https://corsproxy.bunkum.us/corsproxy/?apiurl=https://raw.githubusercontent.com/fgregg/dte-outages/main/${scraped_path}`
  );
  const data = await response.json();
  data.map((d) => {
    d.desc.estimate_fixed = datePattern.test(d.desc.comments)
      ? new Date(`${d.desc.comments}, 2023`)
      : NaN;
    d.desc.start_time = new Date(d.desc.start_time);
    d.desc.lastFileUpdate = new Date(d.desc.lastFileUpdate);
    d.geom.p = polyline.decode(d.geom.p[0])[0].reverse();
    if (d.geom.a) {
      d.geom.a = d.geom.a.map((pl) =>
        polyline.decode(pl).map((pt) => pt.reverse())
      );
    }
  });
  return data;
})();
return {outages};
},
    inputs: ["scraped_path","polyline"],
    outputs: ["outages"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-37`), expanded: [], variables: []},
  {
    id: 37,
    body: () => {
function extractDateFromFilename(filename, prefix) {
  // Assuming the timestamp format is "dte_YYYYMMDDHHmmss.json"
  const timestampRegex = new RegExp(
    `${prefix}(\\d{4})(\\d{2})(\\d{2})(\\d{2})(\\d{2})(\\d{2})\\.json`
  );
  const matches = filename.match(timestampRegex);

  if (matches) {
    const [, year, month, day, hours, minutes, seconds] = matches;
    const dateStr = `${year}-${month}-${day}T${hours}:${minutes}:${seconds}Z`;
    return new Date(dateStr);
  }

  return null; // Return null if the filename doesn't match the expected format.
}
return {extractDateFromFilename};
},
    inputs: [],
    outputs: ["extractDateFromFilename"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-38`), expanded: [], variables: []},
  {
    id: 38,
    body: (extractDateFromFilename,scraped_path) => {
const last_checked = extractDateFromFilename(scraped_path, "dte_");
return {last_checked};
},
    inputs: ["extractDateFromFilename","scraped_path"],
    outputs: ["last_checked"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-39`), expanded: [], variables: []},
  {
    id: 39,
    body: (d3) => {
const dte_outage_history = (() => {
  return d3.csv(
    "https://corsproxy.bunkum.us/corsproxy/?apiurl=https://github.com/fgregg/dte-outages/releases/download/hourly/outage_history.csv",
    d3.autoType
  );
})();
return {dte_outage_history};
},
    inputs: ["d3"],
    outputs: ["dte_outage_history"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-40`), expanded: [], variables: []},
  {
    id: 40,
    body: (d3) => {
const ce_outage_history = (() => {
  return d3.csv(
    "https://corsproxy.bunkum.us/corsproxy/?apiurl=https://github.com/fgregg/consumer-energy-outages/releases/download/hourly/outage_history.csv",
    d3.autoType
  );
})();
return {ce_outage_history};
},
    inputs: ["d3"],
    outputs: ["ce_outage_history"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-41`), expanded: [], variables: []},
  {
    id: 41,
    body: () => {
const house_districts = fetch(
  "https://opendata.arcgis.com/api/v3/datasets/078bf618c3e047ab9358c48f8c735eaa_17/downloads/data?format=geojson&spatialRefId=4326&where=1%3D1"
).then((d) => d.json());
return {house_districts};
},
    inputs: [],
    outputs: ["house_districts"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-42`), expanded: [], variables: []},
  {
    id: 42,
    body: () => {
const senate_districts = fetch(
  "https://opendata.arcgis.com/api/v3/datasets/45e0487cb34c4343b6fad45b97669a19_24/downloads/data?format=geojson&spatialRefId=4326&where=1%3D1"
).then((d) => d.json());
return {senate_districts};
},
    inputs: [],
    outputs: ["senate_districts"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-43`), expanded: [], variables: []},
  {
    id: 43,
    body: (turf,outages,d3) => {
function outages_per_area(featurecollection, index) {
  for (const feature of featurecollection.features) {
    const points_in_bbox = index
      .range(...turf.bbox(feature))
      .map((i) => turf.point(outages[i].geom.p, outages[i].desc));
    const points_in_feature = turf.pointsWithinPolygon(
      turf.featureCollection(points_in_bbox),
      feature
    );
    feature.properties.n_affected_customers = d3.sum(
      points_in_feature.features.map((d) => d.properties.cust_a.val)
    );
  }
  return featurecollection;
}
return {outages_per_area};
},
    inputs: ["turf","outages","d3"],
    outputs: ["outages_per_area"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-44`), expanded: [], variables: []},
  {
    id: 44,
    body: async () => {
const kdbush = (await import("https://esm.sh/kdbush@4")).default;
return {kdbush};
},
    inputs: [],
    outputs: ["kdbush"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-45`), expanded: [], variables: []},
  {
    id: 45,
    body: (kdbush,outages) => {
const indexed_outages = (() => {
  const index = new kdbush(outages.length);
  for (const outage of outages) {
    index.add(...outage.geom.p);
  }
  index.finish();
  return index;
})();
return {indexed_outages};
},
    inputs: ["kdbush","outages"],
    outputs: ["indexed_outages"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-46`), expanded: [], variables: []},
  {
    id: 46,
    body: async () => {
const dte_house_districts = await fetch("/assets/data/dte-outages/dte_house_districts.json").then((r) => r.json());
return {dte_house_districts};
},
    inputs: [],
    outputs: ["dte_house_districts"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-47`), expanded: [], variables: []},
  {
    id: 47,
    body: async () => {
const dte_senate_districts = await fetch("/assets/data/dte-outages/dte_senate_districts.json").then((r) => r.json());
return {dte_senate_districts};
},
    inputs: [],
    outputs: ["dte_senate_districts"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-48`), expanded: [], variables: []},
  {
    id: 48,
    body: (d3,formatDateToYYYYMMDDHHMM,dayOffset,runs) => {
// Inlined from @fgregg/storm-tracks: fetch NOAA NWS storm-cell tracks (only
// when the toggle is on) and group them into continuous runs.
async function stormTracks(date, bbox) {
  const tracks = await d3.csv(
    `https://corsproxy.bunkum.us/corsproxy/?apiurl=https://www.ncdc.noaa.gov/swdiws/csv/nx3structure/${formatDateToYYYYMMDDHHMM(
      new Date(date.getTime() + date.getTimezoneOffset() * 60 * 1000),
    )}:${formatDateToYYYYMMDDHHMM(
      dayOffset(new Date(date.getTime() + date.getTimezoneOffset() * 60 * 1000), 1),
    )}?bbox=${bbox.join(",")}`,
    d3.autoType,
  );
  return d3
    .groups(tracks, (d) => d.WSR_ID + d.CELL_ID)
    .map(([z, grouped]) => runs(grouped))
    .flat();
}
return {stormTracks};
},
    inputs: ["d3","formatDateToYYYYMMDDHHMM","dayOffset","runs"],
    outputs: ["stormTracks"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-49`), expanded: [], variables: []},
  {
    id: 49,
    body: () => {
function runs(run) {
  let run_id = 0;
  let first = true;
  const max_gap = 1000 * 60 * 45;
  let previous_observation = null;
  for (const observation of run) {
    if (first) first = false;
    else if (observation.ZTIME - previous_observation.ZTIME > max_gap) run_id += 1;
    observation.run_id = run_id;
    previous_observation = observation;
  }
  return run;
}
return {runs};
},
    inputs: [],
    outputs: ["runs"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-50`), expanded: [], variables: []},
  {
    id: 50,
    body: () => {
function formatDateToYYYYMMDDHHMM(datetime) {
  const year = datetime.getUTCFullYear();
  const month = String(datetime.getUTCMonth() + 1).padStart(2, "0");
  const day = String(datetime.getUTCDate()).padStart(2, "0");
  const hours = String(datetime.getUTCHours()).padStart(2, "0");
  const minutes = String(datetime.getUTCMinutes()).padStart(2, "0");
  return `${year}${month}${day}${hours}${minutes}`;
}
return {formatDateToYYYYMMDDHHMM};
},
    inputs: [],
    outputs: ["formatDateToYYYYMMDDHHMM"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-51`), expanded: [], variables: []},
  {
    id: 51,
    body: () => {
function dayOffset(date, offset) {
  const offsetDay = new Date(date);
  offsetDay.setDate(offsetDay.getDate() + offset);
  return offsetDay;
}
return {dayOffset};
},
    inputs: [],
    outputs: ["dayOffset"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-52`), expanded: [], variables: []},
  {
    id: 52,
    body: (show_storm_tracks,stormTracks,storm_date) => {
const storm_tracks = show_storm_tracks
  ? stormTracks(storm_date, [-85, 41.5, -81.5, 44.5])
  : [];
return {storm_tracks};
},
    inputs: ["show_storm_tracks","stormTracks","storm_date"],
    outputs: ["storm_tracks"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

</script>]]></content><author><name>Forest Gregg</name></author><summary type="html"><![CDATA[A live map of current DTE and Consumer Energy power outages across southeast Michigan, scraped hourly, with historical trends.]]></summary></entry><entry><title type="html">Cocktail Ingredients Optimizer</title><link href="https://bunkum.us/2025/01/21/cocktail-ingredients-optimizer.html" rel="alternate" type="text/html" title="Cocktail Ingredients Optimizer" /><published>2025-01-21T00:00:00+00:00</published><updated>2025-01-21T00:00:00+00:00</updated><id>https://bunkum.us/2025/01/21/cocktail-ingredients-optimizer</id><content type="html" xml:base="https://bunkum.us/2025/01/21/cocktail-ingredients-optimizer.html"><![CDATA[<p>For a fixed number of ingredients, which ingredients will let you make the most different cocktails?</p>
<p>This notebook uses a branch and bound algorithm, in a web worker, to try to find the best ingredient list for you.</p>

<div id="cell-1" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2022-11-21-trends-in-tier-scores-at-cps-selective-enrollment-high-schools/cell-1.png" alt="chart" style="max-width:100%"></div>

<div id="cell-2" class="reactive-cell"><p>With  ingredients, you can make  cocktail(s).</p>
<table>
<thead>
<tr>
<th>Cocktail</th>
<th>Ingredients</th>
</tr>
</thead>
</table>
<p>Here’s the shopping list:</p>
<table>
<thead>
<tr>
<th>Ingredient</th>
<th>Number of Cocktails</th>
</tr>
</thead>
</table>
</div>

<div id="cell-3" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2022-09-22-cps-decline-and-market-share/cell-3.png" alt="chart" style="max-width:100%"></div>

<div id="cell-4" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2022-11-21-trends-in-tier-scores-at-cps-selective-enrollment-high-schools/cell-4.png" alt="chart" style="max-width:100%"></div>

<div id="cell-5" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2022-09-22-cps-decline-and-market-share/cell-5.png" alt="chart" style="max-width:100%"></div>

<div id="cell-6" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2024-11-26-chicago-policing-force-size-and-budgets/cell-6.png" alt="chart" style="max-width:100%"></div>

<div id="cell-7" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2024-01-01-michigan-property-tax-simulator/cell-7.png" alt="chart" style="max-width:100%"></div>

<div id="cell-8" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2023-06-04-chicago-population-pyramids/cell-8.png" alt="chart" style="max-width:100%"></div>

<div id="cell-9" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2023-11-01-districting-for-the-chicago-public-schools-elected-board/cell-9.png" alt="chart" style="max-width:100%"></div>

<div id="cell-10" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2024-01-01-michigan-property-tax-simulator/cell-10.png" alt="chart" style="max-width:100%"></div>

<div id="cell-11" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2025-04-01-dte-outages/cell-11.png" alt="chart" style="max-width:100%"></div>

<div id="cell-12" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2023-11-01-districting-for-the-chicago-public-schools-elected-board/cell-12.png" alt="chart" style="max-width:100%"></div>

<div id="cell-13" class="reactive-cell"></div>

<div id="cell-14" class="reactive-cell"></div>

<div id="cell-15" class="reactive-cell"></div>

<script type="module">
import {define, main, Plot, Inputs, d3} from "/assets/js/reactive-runtime.js";
// Register Plot, Inputs, and d3 as reactive variables so cells that
// reference them resolve through the dependency graph (not runtime builtins).
main.variable().define("Plot", [], () => Plot);
main.variable().define("Inputs", [], () => Inputs);
main.variable().define("d3", [], () => d3);
// Hydrate an inline ${…}: bind its reactive value to its placeholder span,
// updating only that span — never re-render the surrounding (static) prose.
function hydrate(ref, inputs, body) {
  const el = document.querySelector(`[data-reactive="${ref}"]`);
  if (!el) return;
  main.variable({
    pending() {},
    rejected(error) { console.error(error); },
    fulfilled(value) {
      el.replaceChildren(value instanceof Node ? value : document.createTextNode(value == null ? "" : String(value)));
    }
  }).define(null, inputs, body);
}
define(
  {root: document.getElementById(`cell-1`), expanded: [], variables: []},
  {
    id: 1,
    body: (view,Inputs) => {
const num_ingredients = view(
  Inputs.range([2, 129], {
    label: "Number of Ingredients",
    step: 1,
    value: 30,
  }),
);
return {num_ingredients};
},
    inputs: ["view","Inputs"],
    outputs: ["num_ingredients"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-2`), expanded: [], variables: []},
  {
    id: 2,
    body: (md,num_ingredients,search_results,cocktails,ingredients) => {
return (
md`

With ${num_ingredients} ingredients, you can make ${search_results.length} cocktail(s).

| Cocktail | Ingredients |
|----------|-------------|
${search_results.map((cocktail) => `| ${cocktails.get(cocktail)} | ${cocktail.join(', ')} |`).join("\n")}

Here's the shopping list:

| Ingredient | Number of Cocktails |
|------------|---------------------|
${ingredients.map(details => `| ${details.join(' | ')} |`).join("\n")}

`
)
},
    inputs: ["md","num_ingredients","search_results","cocktails","ingredients"],
    outputs: [],
    output: null,
    autodisplay: true,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-3`), expanded: [], variables: []},
  {
    id: 3,
    body: (search_results) => {
const ingredients = Array.from(
  search_results
    .flat()
    .reduce(
      (accumulator, ingredient) => (
        accumulator.set(ingredient, (accumulator.get(ingredient) ?? 0) + 1),
        accumulator
      ),
      new Map(),
    )
    .entries(),
).sort((a, b) => b[1] - a[1]);
return {ingredients};
},
    inputs: ["search_results"],
    outputs: ["ingredients"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-4`), expanded: [], variables: []},
  {
    id: 4,
    body: (Generators,heuristicScoring,keepExploring,totalBound,singletonBound,isSubsetOf,difference,union,search,num_ingredients,cocktails) => {
// The original ran this in a Web Worker via @fil/worker. Same idea, written
// directly: serialize the helper functions into a worker (so the long search
// doesn't block the page) and stream the best-so-far via Generators.observe.
const search_results = Generators.observe((notify) => {
  const code = `
heuristicScoring = ${heuristicScoring.toString()};
keepExploring = ${keepExploring.toString()};
totalBound = ${totalBound.toString()};
singletonBound = ${singletonBound.toString()};
isSubsetOf = ${isSubsetOf.toString()};
difference = ${difference.toString()};
union = ${union.toString()};
const search = ${search.toString()};
self.onmessage = (e) => {
  for (const best of search(e.data)) self.postMessage(best);
  self.close();
};
`;
  const url = URL.createObjectURL(
    new Blob([code], { type: "text/javascript" }),
  );
  const w = new Worker(url);
  w.onmessage = (ev) => notify(ev.data);
  w.postMessage({
    calls: 10000000,
    maxSize: num_ingredients,
    candidates: Array.from(cocktails.keys()),
  });
  return () => {
    w.terminate();
    URL.revokeObjectURL(url);
  };
});
return {search_results};
},
    inputs: ["Generators","heuristicScoring","keepExploring","totalBound","singletonBound","isSubsetOf","difference","union","search","num_ingredients","cocktails"],
    outputs: ["search_results"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-5`), expanded: [], variables: []},
  {
    id: 5,
    body: (heuristicScoring,keepExploring,union,isSubsetOf,difference) => {
function* search(options) {
  const maxSize = options.maxSize;
  let remainingCalls = options.calls;
  let highestScore = 0;
  let highest = [];

  const original_candidates = options.candidates.filter(
    (cocktail) => cocktail.length <= maxSize,
  );

  heuristicScoring(original_candidates);
  original_candidates.sort((a, b) => a.cost - b.cost);

  const toExplore = [
    [original_candidates, options.partial ?? [], options.forbidden ?? []],
  ];

  while (toExplore.length > 0 && remainingCalls--) {
    const [candidates, partial, forbidden] = toExplore.pop();
    const score = partial.length;

    if (score > highestScore) {
      highest = [...partial];
      highestScore = score;

      yield highest;
    }

    const partialIngredients = new Set(partial.flat());

    const shouldContinue = keepExploring(
      candidates,
      partial,
      partialIngredients,
      highestScore,
      maxSize,
      forbidden,
    );

    if (shouldContinue) {
      const best = candidates[0];

      const newPartialIngredients = union(best, partialIngredients);
      const coveredCandidates = new Set(
        candidates.filter((cocktail) =>
          isSubsetOf(cocktail, newPartialIngredients),
        ),
      );

      const permittedCandidates = difference(
        candidates,
        coveredCandidates,
      ).filter(
        (cocktail) => union(cocktail, newPartialIngredients).size <= maxSize,
      );

      const remaining = candidates.filter(
        (cocktail) =>
          !isSubsetOf(
            best.filter((ingredient) => !cocktail.includes(ingredient)),
            partialIngredients,
          ),
      );

      toExplore.push([remaining, partial, [...forbidden, best]]);

      toExplore.push([
        permittedCandidates,
        [...partial, ...coveredCandidates],
        forbidden,
      ]);
    }
  }
}
return {search};
},
    inputs: ["heuristicScoring","keepExploring","union","isSubsetOf","difference"],
    outputs: ["search"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-6`), expanded: [], variables: []},
  {
    id: 6,
    body: () => {
const heuristicScoring = (candidates) => {
  const cardinality = candidates
    .flat()
    .reduce(
      (acc, ingredient) => (
        acc.set(ingredient, (acc.get(ingredient) ?? 0) + 1),
        acc
      ),
      new Map(),
    );

  candidates.forEach((cocktail) => {
    const cost = cocktail.reduce(
      (sum, ingredient) => sum + 1 / cardinality.get(ingredient),
      0,
    );
    cocktail.cost = cost;

    if (cocktail.some((ingredient) => cardinality.get(ingredient) === 1)) {
      cocktail.singular = true;
    } else {
      cocktail.singular = false;
    }
  });
};
return {heuristicScoring};
},
    inputs: [],
    outputs: ["heuristicScoring"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-7`), expanded: [], variables: []},
  {
    id: 7,
    body: (totalBound,singletonBound,isSubsetOf) => {
const keepExploring = (
  candidates,
  partial,
  partialIngredients,
  highestScore,
  maxSize,
  forbidden,
) => {
  const threshold = highestScore - partial.length;

  return (
    [totalBound, singletonBound].every(
      (bound) => bound(candidates, partialIngredients, maxSize) > threshold,
    ) &&
    !forbidden.some((forbiddenCocktail) =>
      isSubsetOf(forbiddenCocktail, partialIngredients),
    )
  );
};
return {keepExploring};
},
    inputs: ["totalBound","singletonBound","isSubsetOf"],
    outputs: ["keepExploring"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-8`), expanded: [], variables: []},
  {
    id: 8,
    body: () => {
const totalBound = (candidates) => candidates.length;
return {totalBound};
},
    inputs: [],
    outputs: ["totalBound"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-9`), expanded: [], variables: []},
  {
    id: 9,
    body: () => {
const singletonBound = (candidates, partialIngredients, maxSize) => {
  /**
   * There are many cocktails that have an unique ingredient.
   *
   * Each cocktail with a unique ingredient will cost at least
   * one ingredient from our ingredient budget and the total
   * possible increase due to these unique cocktails is bounded
   * by the ingredient budget
   */

  const nUniqueCocktails = candidates.filter(
    (cocktail) => cocktail.singular,
  ).length;

  const ingredientBudget = maxSize - partialIngredients.size;

  const upperIncrement =
    candidates.length -
    nUniqueCocktails +
    Math.min(nUniqueCocktails, ingredientBudget);

  return upperIncrement;
};
return {singletonBound};
},
    inputs: [],
    outputs: ["singletonBound"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-10`), expanded: [], variables: []},
  {
    id: 10,
    body: (ArrayKeyedMap,data) => {
const cocktails = new ArrayKeyedMap(
  Object.entries(data).map(([name, ingredients]) => [ingredients.sort(), name]),
);
return {cocktails};
},
    inputs: ["ArrayKeyedMap","data"],
    outputs: ["cocktails"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-11`), expanded: [], variables: []},
  {
    id: 11,
    body: () => {
const isSubsetOf = (A, B) =>
  A.length <= B.size && A.every((element) => B.has(element));
return {isSubsetOf};
},
    inputs: [],
    outputs: ["isSubsetOf"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-12`), expanded: [], variables: []},
  {
    id: 12,
    body: () => {
const difference = (A, B) => A.filter((element) => !B.has(element));
return {difference};
},
    inputs: [],
    outputs: ["difference"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-13`), expanded: [], variables: []},
  {
    id: 13,
    body: () => {
const union = (A, B) => {
  return new Set([...A, ...B]);
};
return {union};
},
    inputs: [],
    outputs: ["union"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-14`), expanded: [], variables: []},
  {
    id: 14,
    body: async () => {
const data = await fetch(
  "/assets/data/cocktail-ingredients-optimizer/ingredients.json",
).then((r) => r.json());
return {data};
},
    inputs: [],
    outputs: ["data"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-15`), expanded: [], variables: []},
  {
    id: 15,
    body: async () => {
const ArrayKeyedMap = (await import("https://esm.sh/array-keyed-map@2.1.3"))
  .default;
return {ArrayKeyedMap};
},
    inputs: [],
    outputs: ["ArrayKeyedMap"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

</script>]]></content><author><name>Forest Gregg</name></author><summary type="html"><![CDATA[A branch-and-bound search for the set of N bar ingredients that lets you make the most distinct cocktails.]]></summary></entry><entry><title type="html">What are they growing around here?</title><link href="https://bunkum.us/2024/12/30/what-are-they-growing-around-here.html" rel="alternate" type="text/html" title="What are they growing around here?" /><published>2024-12-30T00:00:00+00:00</published><updated>2024-12-30T00:00:00+00:00</updated><id>https://bunkum.us/2024/12/30/what-are-they-growing-around-here</id><content type="html" xml:base="https://bunkum.us/2024/12/30/what-are-they-growing-around-here.html"><![CDATA[<p>Data from the <a href="https://www.nass.usda.gov/Research_and_Science/Cropland/SARS1a.php">2023 National Cropland Data Layer</a> from the United States Department of Agriculture’s National Agricultural Statistics Service.</p>
<p>To see the crops around your current location, <a href="/2024/12/28/whats-growing-around-here-road-trip-version.html">see this post</a>.</p>

<div id="cell-1" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2022-11-21-trends-in-tier-scores-at-cps-selective-enrollment-high-schools/cell-1.png" alt="chart" style="max-width:100%"></div>

<div id="cell-2" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2023-06-04-chicago-population-pyramids/cell-2.png" alt="chart" style="max-width:100%"></div>

<div id="cell-3" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2022-09-22-cps-decline-and-market-share/cell-3.png" alt="chart" style="max-width:100%"></div>

<div id="cell-4" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2022-11-21-trends-in-tier-scores-at-cps-selective-enrollment-high-schools/cell-4.png" alt="chart" style="max-width:100%"></div>

<div id="cell-5" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2022-09-22-cps-decline-and-market-share/cell-5.png" alt="chart" style="max-width:100%"></div>

<div id="cell-6" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2024-11-26-chicago-policing-force-size-and-budgets/cell-6.png" alt="chart" style="max-width:100%"></div>

<div id="cell-7" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2024-01-01-michigan-property-tax-simulator/cell-7.png" alt="chart" style="max-width:100%"></div>

<div id="cell-8" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2023-06-04-chicago-population-pyramids/cell-8.png" alt="chart" style="max-width:100%"></div>

<div id="cell-9" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2023-11-01-districting-for-the-chicago-public-schools-elected-board/cell-9.png" alt="chart" style="max-width:100%"></div>

<div id="cell-10" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2024-01-01-michigan-property-tax-simulator/cell-10.png" alt="chart" style="max-width:100%"></div>

<div id="cell-11" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2025-04-01-dte-outages/cell-11.png" alt="chart" style="max-width:100%"></div>

<div id="cell-12" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2023-11-01-districting-for-the-chicago-public-schools-elected-board/cell-12.png" alt="chart" style="max-width:100%"></div>

<div id="cell-13" class="reactive-cell"></div>

<div id="cell-14" class="reactive-cell"></div>

<div id="cell-15" class="reactive-cell"></div>

<div id="cell-16" class="reactive-cell"></div>

<div id="cell-17" class="reactive-cell"></div>

<div id="cell-18" class="reactive-cell"></div>

<div id="cell-19" class="reactive-cell"></div>

<div id="cell-20" class="reactive-cell"></div>

<div id="cell-21" class="reactive-cell"></div>

<div id="cell-22" class="reactive-cell"></div>

<div id="cell-23" class="reactive-cell"></div>

<script type="module">
import {define, main, Plot, Inputs, d3} from "/assets/js/reactive-runtime.js";
// Register Plot, Inputs, and d3 as reactive variables so cells that
// reference them resolve through the dependency graph (not runtime builtins).
main.variable().define("Plot", [], () => Plot);
main.variable().define("Inputs", [], () => Inputs);
main.variable().define("d3", [], () => d3);
// Hydrate an inline ${…}: bind its reactive value to its placeholder span,
// updating only that span — never re-render the surrounding (static) prose.
function hydrate(ref, inputs, body) {
  const el = document.querySelector(`[data-reactive="${ref}"]`);
  if (!el) return;
  main.variable({
    pending() {},
    rejected(error) { console.error(error); },
    fulfilled(value) {
      el.replaceChildren(value instanceof Node ? value : document.createTextNode(value == null ? "" : String(value)));
    }
  }).define(null, inputs, body);
}
define(
  {root: document.getElementById(`cell-1`), expanded: [], variables: []},
  {
    id: 1,
    body: (view,Inputs,state_crop_map) => {
const selected_crop = view(Inputs.select(state_crop_map, {
  label: "Select a crop"
}));
return {selected_crop};
},
    inputs: ["view","Inputs","state_crop_map"],
    outputs: ["selected_crop"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-2`), expanded: [], variables: []},
  {
    id: 2,
    body: (view,Inputs,lower_48) => {
const selected_state_name = view(Inputs.select(
  [
    "Lower 48 States",
    ...lower_48.features.map((feature) => feature.properties.name).sort()
  ],
  { label: "Select a state", value: "Lower 48 States" }
));
return {selected_state_name};
},
    inputs: ["view","Inputs","lower_48"],
    outputs: ["selected_state_name"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-3`), expanded: [], variables: []},
  {
    id: 3,
    body: (display,Plot,selected_state_name,lower_48,selected_state,select_crop,state_raster_values,selected_crop,state_raster_bbox) => {
display(
Plot.plot({
  projection: {
    type: "reflect-y",
    domain:
      selected_state_name === "Lower 48 States" ? lower_48 : selected_state
  },
  color: { range: ["white", "green"], domain: [0, 1] },
  marks: [
    Plot.raster(select_crop(state_raster_values[0], selected_crop), {
      x1: state_raster_bbox[0],
      y1: state_raster_bbox[1],
      x2: state_raster_bbox[2],
      y2: state_raster_bbox[3],
      width: state_raster_values.width,
      height: state_raster_values.height
    }),
    Plot.geo(lower_48, { stroke: "gray" })
  ]
})
);
},
    inputs: ["display","Plot","selected_state_name","lower_48","selected_state","select_crop","state_raster_values","selected_crop","state_raster_bbox"],
    outputs: [],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-4`), expanded: [], variables: []},
  {
    id: 4,
    body: (selected_state_name,crops_map,state_raster_values,crop_mappings) => {
const state_crop_map = (() => {
  if (selected_state_name === "Lower 48 States") {
    return crops_map;
  } else {
    const state_crops = new Set(
      state_raster_values[0].map((d) => (Math.random() < 0.01 ? d : null))
    );
    return new Map(
      crop_mappings.filter(([crop, index]) => state_crops.has(index))
    );
  }
})();
return {state_crop_map};
},
    inputs: ["selected_state_name","crops_map","state_raster_values","crop_mappings"],
    outputs: ["state_crop_map"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-5`), expanded: [], variables: []},
  {
    id: 5,
    body: (selected_state_name,state_image,pixel_state_window) => {
const state_raster_values = selected_state_name === "Lower 48 States"
  ? state_image.readRasters()
  : state_image.readRasters({
      window: pixel_state_window
    });
return {state_raster_values};
},
    inputs: ["selected_state_name","state_image","pixel_state_window"],
    outputs: ["state_raster_values"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-6`), expanded: [], variables: []},
  {
    id: 6,
    body: (selected_state_name,bbox,pixel_state_window,state_image) => {
const state_raster_bbox = (() => {
  if (selected_state_name === "Lower 48 States") {
    return [bbox[0], bbox[3], bbox[2], bbox[1]];
  } else {
    const [x1, y1, x2, y2] = pixel_state_window;
    return [
      (x1 / state_image.getWidth()) * (bbox[2] - bbox[0]) + bbox[0],
      (y1 / state_image.getHeight()) * (bbox[1] - bbox[3]) + bbox[3],
      (x2 / state_image.getWidth()) * (bbox[2] - bbox[0]) + bbox[0],
      (y2 / state_image.getHeight()) * (bbox[1] - bbox[3]) + bbox[3]
    ];
  }
})();
return {state_raster_bbox};
},
    inputs: ["selected_state_name","bbox","pixel_state_window","state_image"],
    outputs: ["state_raster_bbox"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-7`), expanded: [], variables: []},
  {
    id: 7,
    body: (state_image,bbox,state_bbox) => {
const pixel_state_window = (() => {
  const width_scale = state_image.getWidth() / (bbox[2] - bbox[0]);
  const height_scale = state_image.getHeight() / (bbox[1] - bbox[3]);

  return [
    ~~((state_bbox[0][0] - bbox[0]) * width_scale),
    ~~((state_bbox[1][1] - bbox[3]) * height_scale),
    ~~((state_bbox[1][0] - bbox[0]) * width_scale),
    ~~((state_bbox[0][1] - bbox[3]) * height_scale)
  ];
})();
return {pixel_state_window};
},
    inputs: ["state_image","bbox","state_bbox"],
    outputs: ["pixel_state_window"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-8`), expanded: [], variables: []},
  {
    id: 8,
    body: (crops_raster,state_image_zoom) => {
const state_image = crops_raster.getImage(state_image_zoom);
return {state_image};
},
    inputs: ["crops_raster","state_image_zoom"],
    outputs: ["state_image"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-9`), expanded: [], variables: []},
  {
    id: 9,
    body: (selected_state_name) => {
const state_image_zoom = selected_state_name === "Lower 48 States" ? 6 : 4;
return {state_image_zoom};
},
    inputs: ["selected_state_name"],
    outputs: ["state_image_zoom"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-10`), expanded: [], variables: []},
  {
    id: 10,
    body: (d3,selected_state) => {
const state_bbox = d3.geoPath().bounds(selected_state);
return {state_bbox};
},
    inputs: ["d3","selected_state"],
    outputs: ["state_bbox"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-11`), expanded: [], variables: []},
  {
    id: 11,
    body: (selected_state_name,lower_48) => {
const selected_state = selected_state_name === "Lower 48 States"
  ? null
  : lower_48.features.find((d) => d.properties.name === selected_state_name);
return {selected_state};
},
    inputs: ["selected_state_name","lower_48"],
    outputs: ["selected_state"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-12`), expanded: [], variables: []},
  {
    id: 12,
    body: () => {
const select_crop = (original_raster, selected_crop) => {
  const result = new Uint8Array(original_raster.length);
  for (let i = 0; i < original_raster.length; i++) {
    const pixel = original_raster[i];
    if (pixel && pixel === selected_crop) {
      // background pixels are 0, so the && short-circuits those
      result[i] = 1;
    }
  }
  return result;
};
return {select_crop};
},
    inputs: [],
    outputs: ["select_crop"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-13`), expanded: [], variables: []},
  {
    id: 13,
    body: (structuredClone,states,project) => {
const lower_48 = (() => {
  const projected = structuredClone(states);
  const projectedFeatures = projected.features.filter(
    (d) =>
      !new Set([
        "Alaska",
        "Hawaii",
        "Puerto Rico",
        "Guam",
        "Commonwealth of the Northern Mariana Islands",
        "United States Virgin Islands",
        "American Samoa"
      ]).has(d.properties.name)
  );
  for (const feature of projectedFeatures) {
    const projectedGeometry = {
      type: feature.geometry.type,
      coordinates: feature.geometry.coordinates.map((island) =>
        island.map((ring) => ring.map((coord) => project.forward(coord)))
      )
    };
    feature.geometry = projectedGeometry;
  }
  projected.features = projectedFeatures;
  return projected;
})();
return {lower_48};
},
    inputs: ["structuredClone","states","project"],
    outputs: ["lower_48"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-14`), expanded: [], variables: []},
  {
    id: 14,
    body: (proj4) => {
const project = proj4(
  "+proj=aea +lat_0=23 +lon_0=-96 +lat_1=29.5 +lat_2=45.5 +x_0=0 +y_0=0 +datum=NAD83 +units=m +no_defs"
);
return {project};
},
    inputs: ["proj4"],
    outputs: ["project"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-15`), expanded: [], variables: []},
  {
    id: 15,
    body: (topojson,us) => {
const states = topojson.feature(us, us.objects.states);
return {states};
},
    inputs: ["topojson","us"],
    outputs: ["states"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-16`), expanded: [], variables: []},
  {
    id: 16,
    body: (crop_mappings) => {
const crops_map = new Map(crop_mappings);
return {crops_map};
},
    inputs: ["crop_mappings"],
    outputs: ["crops_map"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-17`), expanded: [], variables: []},
  {
    id: 17,
    body: async (crops_raster) => {
const bbox = (await crops_raster.getImage()).getBoundingBox();
return {bbox};
},
    inputs: ["crops_raster"],
    outputs: ["bbox"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-18`), expanded: [], variables: []},
  {
    id: 18,
    body: (geotiff) => {
const crops_raster = geotiff.fromUrl("https://storage.bunkum.us/2023_clds.tif");
return {crops_raster};
},
    inputs: ["geotiff"],
    outputs: ["crops_raster"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-19`), expanded: [], variables: []},
  {
    id: 19,
    body: async () => {
// geotiff + topojson via CDN (Observable loaded them via require / stdlib).
const geotiff = await import("https://esm.sh/geotiff@2.1.3");
return {geotiff};
},
    inputs: [],
    outputs: ["geotiff"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-20`), expanded: [], variables: []},
  {
    id: 20,
    body: async () => {
const topojson = await import("https://esm.sh/topojson-client@3");
return {topojson};
},
    inputs: [],
    outputs: ["topojson"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-21`), expanded: [], variables: []},
  {
    id: 21,
    body: async () => {
const proj4 = (await import("https://esm.sh/proj4@2.8")).default;
return {proj4};
},
    inputs: [],
    outputs: ["proj4"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-22`), expanded: [], variables: []},
  {
    id: 22,
    body: async () => {
const us = await fetch("/assets/data/what-are-they-growing/us-counties-10m.json").then((r) => r.json());
return {us};
},
    inputs: [],
    outputs: ["us"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-23`), expanded: [], variables: []},
  {
    id: 23,
    body: async () => {
const crop_mappings = await fetch("/assets/data/what-are-they-growing/crop_mappings.json").then((r) => r.json());
return {crop_mappings};
},
    inputs: [],
    outputs: ["crop_mappings"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

</script>]]></content><author><name>Forest Gregg</name></author><summary type="html"><![CDATA[A live map of what crop is grown where, from the USDA Cropland Data Layer raster, by county.]]></summary></entry><entry><title type="html">What’s Growing Around Here (Road Trip Version)?</title><link href="https://bunkum.us/2024/12/28/whats-growing-around-here-road-trip-version.html" rel="alternate" type="text/html" title="What’s Growing Around Here (Road Trip Version)?" /><published>2024-12-28T00:00:00+00:00</published><updated>2024-12-28T00:00:00+00:00</updated><id>https://bunkum.us/2024/12/28/whats-growing-around-here-road-trip-version</id><content type="html" xml:base="https://bunkum.us/2024/12/28/whats-growing-around-here-road-trip-version.html"><![CDATA[<p>Here are the crops and land cover at your location in 2023, according to the
<a href="https://www.nass.usda.gov/Research_and_Science/Cropland/SARS1a.php">2023 National Cropland Data Layer</a>
from the United States Department of Agriculture’s National Agricultural
Statistics Service.</p>
<p>For nice maps, see <a href="/2024/12/30/what-are-they-growing-around-here.html">this post</a>.</p>

<div id="cell-1" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2022-11-21-trends-in-tier-scores-at-cps-selective-enrollment-high-schools/cell-1.png" alt="chart" style="max-width:100%"></div>

<div id="cell-2" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2023-06-04-chicago-population-pyramids/cell-2.png" alt="chart" style="max-width:100%"></div>

<div id="cell-3" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2022-09-22-cps-decline-and-market-share/cell-3.png" alt="chart" style="max-width:100%"></div>

<div id="cell-4" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2022-11-21-trends-in-tier-scores-at-cps-selective-enrollment-high-schools/cell-4.png" alt="chart" style="max-width:100%"></div>

<div id="cell-5" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2022-09-22-cps-decline-and-market-share/cell-5.png" alt="chart" style="max-width:100%"></div>

<div id="cell-6" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2024-11-26-chicago-policing-force-size-and-budgets/cell-6.png" alt="chart" style="max-width:100%"></div>

<div id="cell-7" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2024-01-01-michigan-property-tax-simulator/cell-7.png" alt="chart" style="max-width:100%"></div>

<div id="cell-8" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2023-06-04-chicago-population-pyramids/cell-8.png" alt="chart" style="max-width:100%"></div>

<div id="cell-9" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2023-11-01-districting-for-the-chicago-public-schools-elected-board/cell-9.png" alt="chart" style="max-width:100%"></div>

<div id="cell-10" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2024-01-01-michigan-property-tax-simulator/cell-10.png" alt="chart" style="max-width:100%"></div>

<div id="cell-11" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2025-04-01-dte-outages/cell-11.png" alt="chart" style="max-width:100%"></div>

<div id="cell-12" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2023-11-01-districting-for-the-chicago-public-schools-elected-board/cell-12.png" alt="chart" style="max-width:100%"></div>

<div id="cell-13" class="reactive-cell"></div>

<div id="cell-14" class="reactive-cell"></div>

<div id="cell-15" class="reactive-cell"></div>

<div id="cell-16" class="reactive-cell"></div>

<div id="cell-17" class="reactive-cell"></div>

<div id="cell-18" class="reactive-cell"></div>

<div id="cell-19" class="reactive-cell"></div>

<script type="module">
import {define, main, Plot, Inputs, d3} from "/assets/js/reactive-runtime.js";
// Register Plot, Inputs, and d3 as reactive variables so cells that
// reference them resolve through the dependency graph (not runtime builtins).
main.variable().define("Plot", [], () => Plot);
main.variable().define("Inputs", [], () => Inputs);
main.variable().define("d3", [], () => d3);
// Hydrate an inline ${…}: bind its reactive value to its placeholder span,
// updating only that span — never re-render the surrounding (static) prose.
function hydrate(ref, inputs, body) {
  const el = document.querySelector(`[data-reactive="${ref}"]`);
  if (!el) return;
  main.variable({
    pending() {},
    rejected(error) { console.error(error); },
    fulfilled(value) {
      el.replaceChildren(value instanceof Node ? value : document.createTextNode(value == null ? "" : String(value)));
    }
  }).define(null, inputs, body);
}
define(
  {root: document.getElementById(`cell-1`), expanded: [], variables: []},
  {
    id: 1,
    body: (view,Inputs) => {
const radius = view(
  Inputs.select(
    new Map([
      ["100 yards", 0.05681818],
      ["quarter mile", 0.25],
      ["half mile", 0.5],
      ["mile", 1],
      ["two miles", 2],
    ]),
    {
      label: "Radius",
      value: "100 yards",
    },
  ),
);
return {radius};
},
    inputs: ["view","Inputs"],
    outputs: ["radius"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-2`), expanded: [], variables: []},
  {
    id: 2,
    body: (display,htmlTable,dataTable) => {
display(htmlTable(dataTable));
},
    inputs: ["display","htmlTable","dataTable"],
    outputs: [],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-3`), expanded: [], variables: []},
  {
    id: 3,
    body: (geoposition) => {
const position = geoposition;
return {position};
},
    inputs: ["geoposition"],
    outputs: ["position"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-4`), expanded: [], variables: []},
  {
    id: 4,
    body: (crop_mappings,landuseProportions) => {
const dataTable = crop_mappings
  .map(([name, id]) => ({
    name,
    prop: landuseProportions.get(id),
  }))
  .filter((d) => d.prop)
  .sort((a, b) => b.prop - a.prop)
  .map(({ name, prop }) => ({
    name,
    percent: Math.round(prop * 100),
  }));
return {dataTable};
},
    inputs: ["crop_mappings","landuseProportions"],
    outputs: ["dataTable"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-5`), expanded: [], variables: []},
  {
    id: 5,
    body: (d3,values) => {
const landuseProportions = d3.rollup(
  values[0],
  (v) => v.length / values[0].length,
  (d) => d,
);
return {landuseProportions};
},
    inputs: ["d3","values"],
    outputs: ["landuseProportions"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-6`), expanded: [], variables: []},
  {
    id: 6,
    body: (high_res_image,pixel_state_window) => {
const values = high_res_image.readRasters({
  window: pixel_state_window,
  fillValue: 0,
});
return {values};
},
    inputs: ["high_res_image","pixel_state_window"],
    outputs: ["values"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-7`), expanded: [], variables: []},
  {
    id: 7,
    body: (high_res_image,bbox,projectedLocalBbox) => {
const pixel_state_window = (() => {
  const width_scale = high_res_image.getWidth() / (bbox[2] - bbox[0]);
  const height_scale = high_res_image.getHeight() / (bbox[1] - bbox[3]);

  return [
    ~~((projectedLocalBbox[0][0] - bbox[0]) * width_scale),
    ~~((projectedLocalBbox[1][1] - bbox[3]) * height_scale),
    ~~((projectedLocalBbox[1][0] - bbox[0]) * width_scale),
    ~~((projectedLocalBbox[0][1] - bbox[3]) * height_scale),
  ];
})();
return {pixel_state_window};
},
    inputs: ["high_res_image","bbox","projectedLocalBbox"],
    outputs: ["pixel_state_window"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-8`), expanded: [], variables: []},
  {
    id: 8,
    body: (project,localBbox) => {
const projectedLocalBbox = [
  project.forward([localBbox.minLon, localBbox.minLat]),
  project.forward([localBbox.maxLon, localBbox.maxLat]),
];
return {projectedLocalBbox};
},
    inputs: ["project","localBbox"],
    outputs: ["projectedLocalBbox"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-9`), expanded: [], variables: []},
  {
    id: 9,
    body: (proj4) => {
const project = proj4(
  "+proj=aea +lat_0=23 +lon_0=-96 +lat_1=29.5 +lat_2=45.5 +x_0=0 +y_0=0 +datum=NAD83 +units=m +no_defs",
);
return {project};
},
    inputs: ["proj4"],
    outputs: ["project"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-10`), expanded: [], variables: []},
  {
    id: 10,
    body: (bboxAroundPoint,position,radius) => {
const localBbox = bboxAroundPoint(
  position.coords.latitude,
  position.coords.longitude,
  radius,
);
return {localBbox};
},
    inputs: ["bboxAroundPoint","position","radius"],
    outputs: ["localBbox"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-11`), expanded: [], variables: []},
  {
    id: 11,
    body: () => {
function bboxAroundPoint(lat, lon, distanceInMiles = 1) {
  const latDegreeMiles = 69; // Approx. miles per degree of latitude
  const latDelta = distanceInMiles / latDegreeMiles;

  const lonDegreeMiles = Math.cos(lat * (Math.PI / 180)) * latDegreeMiles;
  const lonDelta = distanceInMiles / lonDegreeMiles;

  const minLat = lat - latDelta;
  const maxLat = lat + latDelta;
  const minLon = lon - lonDelta;
  const maxLon = lon + lonDelta;

  return {
    minLat,
    minLon,
    maxLat,
    maxLon,
  };
}
return {bboxAroundPoint};
},
    inputs: [],
    outputs: ["bboxAroundPoint"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-12`), expanded: [], variables: []},
  {
    id: 12,
    body: (crops_raster) => {
const high_res_image = crops_raster.getImage(0);
return {high_res_image};
},
    inputs: ["crops_raster"],
    outputs: ["high_res_image"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-13`), expanded: [], variables: []},
  {
    id: 13,
    body: async (crops_raster) => {
const bbox = (await crops_raster.getImage()).getBoundingBox();
return {bbox};
},
    inputs: ["crops_raster"],
    outputs: ["bbox"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-14`), expanded: [], variables: []},
  {
    id: 14,
    body: (geotiff) => {
const crops_raster = geotiff.fromUrl("https://storage.bunkum.us/2023_clds.tif");
return {crops_raster};
},
    inputs: ["geotiff"],
    outputs: ["crops_raster"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-15`), expanded: [], variables: []},
  {
    id: 15,
    body: async () => {
const crop_mappings = await fetch(
  "/assets/data/whats-growing-road-trip-version/crop_mappings.json",
).then((r) => r.json());
return {crop_mappings};
},
    inputs: [],
    outputs: ["crop_mappings"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-16`), expanded: [], variables: []},
  {
    id: 16,
    body: async () => {
const geotiff = await import("https://esm.sh/geotiff@2.1.3");
return {geotiff};
},
    inputs: [],
    outputs: ["geotiff"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-17`), expanded: [], variables: []},
  {
    id: 17,
    body: async () => {
const proj4 = (await import("https://esm.sh/proj4@2.8")).default;
return {proj4};
},
    inputs: [],
    outputs: ["proj4"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-18`), expanded: [], variables: []},
  {
    id: 18,
    body: (html) => {
// Standard HTML table (picks up the site table CSS); wrapped for horizontal
// scroll on narrow screens.
const htmlTable = (rows) => {
  const cols = rows.length ? Object.keys(rows[0]) : [];
  return html`<div style="overflow-x: auto">
    <table>
      <thead>
        <tr>
          ${cols.map((c) => html`<th>${c}</th>`)}
        </tr>
      </thead>
      <tbody>
        ${rows.map(
          (r) => html`<tr>
            ${cols.map((c) => html`<td>${r[c]}</td>`)}
          </tr>`,
        )}
      </tbody>
    </table>
  </div>`;
};
return {htmlTable};
},
    inputs: ["html"],
    outputs: ["htmlTable"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-19`), expanded: [], variables: []},
  {
    id: 19,
    body: (Generators,GeolocationPositionError) => {
const geoposition = Generators.observe((next) => {
  navigator.geolocation.watchPosition(
    next,
    (error) => {
      if (error instanceof GeolocationPositionError) {
        console.error("Geoposition error:", error.message);
      }
    },
    {
      enableHighAccuracy: true,
      maximumAge: 1000,
    },
  );
});
return {geoposition};
},
    inputs: ["Generators","GeolocationPositionError"],
    outputs: ["geoposition"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

</script>]]></content><author><name>Forest Gregg</name></author><summary type="html"><![CDATA[What crop is grown right around you?]]></summary></entry><entry><title type="html">Random Spotify User Search</title><link href="https://bunkum.us/2024/12/19/random-spotify-user-search.html" rel="alternate" type="text/html" title="Random Spotify User Search" /><published>2024-12-19T00:00:00+00:00</published><updated>2024-12-19T00:00:00+00:00</updated><id>https://bunkum.us/2024/12/19/random-spotify-user-search</id><content type="html" xml:base="https://bunkum.us/2024/12/19/random-spotify-user-search.html"><![CDATA[<p>I like to listen to random playlists as way to get a flavor of strangers’ personalities and encounter new music and genre.</p>
<p>I haven’t found a way to get a random playlist, but here’s a method for finding a random user.</p>
<p>Spotify users can be identified with a 25 character string of lowercase English letters and the digits 0-9 (base 36?). If you search for short string of characters, it will search for user identifiers that start with string.</p>
<p>A five character prefix usually returns between one and five users, and then you can choose a user and see if they have playlists.</p>

<div id="cell-1" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2022-11-21-trends-in-tier-scores-at-cps-selective-enrollment-high-schools/cell-1.png" alt="chart" style="max-width:100%"></div>

<p>App friendly:  &lt;a href=“<a href="https://play.spotify.com/search/">https://play.spotify.com/search/</a><span data-reactive="2-0">101,000—104,000</span>” target=“_blank”&gt;<a href="https://play.spotify.com/search/">https://play.spotify.com/search/</a><span data-reactive="2-1">145,000—149,000</span>&lt;/a&gt;</p>
<p>More specific search for desktop:  &lt;a href=“<a href="https://play.spotify.com/search/">https://play.spotify.com/search/</a><span data-reactive="2-2">33,100—33,900</span>/users” target=“_blank”&gt;<a href="https://play.spotify.com/search/">https://play.spotify.com/search/</a><span data-reactive="2-3">21,600—22,100</span>/users&lt;/a&gt;</p>

<div id="cell-3" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2022-09-22-cps-decline-and-market-share/cell-3.png" alt="chart" style="max-width:100%"></div>

<div id="cell-4" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2022-11-21-trends-in-tier-scores-at-cps-selective-enrollment-high-schools/cell-4.png" alt="chart" style="max-width:100%"></div>

<div id="cell-5" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2022-09-22-cps-decline-and-market-share/cell-5.png" alt="chart" style="max-width:100%"></div>

<script type="module">
import {define, main, Plot, Inputs, d3} from "/assets/js/reactive-runtime.js";
// Register Plot, Inputs, and d3 as reactive variables so cells that
// reference them resolve through the dependency graph (not runtime builtins).
main.variable().define("Plot", [], () => Plot);
main.variable().define("Inputs", [], () => Inputs);
main.variable().define("d3", [], () => d3);
// Hydrate an inline ${…}: bind its reactive value to its placeholder span,
// updating only that span — never re-render the surrounding (static) prose.
function hydrate(ref, inputs, body) {
  const el = document.querySelector(`[data-reactive="${ref}"]`);
  if (!el) return;
  main.variable({
    pending() {},
    rejected(error) { console.error(error); },
    fulfilled(value) {
      el.replaceChildren(value instanceof Node ? value : document.createTextNode(value == null ? "" : String(value)));
    }
  }).define(null, inputs, body);
}
define(
  {root: document.getElementById(`cell-1`), expanded: [], variables: []},
  {
    id: 1,
    body: (view,Inputs) => {
const updateButton = view(Inputs.button("Update"));
return {updateButton};
},
    inputs: ["view","Inputs"],
    outputs: ["updateButton"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

hydrate("2-0", ["user_hash_prefix"], (user_hash_prefix) => {
return (
user_hash_prefix
)
});
hydrate("2-1", ["user_hash_prefix"], (user_hash_prefix) => {
return (
user_hash_prefix
)
});
hydrate("2-2", ["user_hash_prefix"], (user_hash_prefix) => {
return (
user_hash_prefix
)
});
hydrate("2-3", ["user_hash_prefix"], (user_hash_prefix) => {
return (
user_hash_prefix
)
});
define(
  {root: document.getElementById(`cell-3`), expanded: [], variables: []},
  {
    id: 3,
    body: (updateButton,choices) => {
const user_hash_prefix = (() => {
  updateButton;
  
  return choices("abcdefghijklmnopqrstuvwxyz1234567890", 5).join("");
})();
return {user_hash_prefix};
},
    inputs: ["updateButton","choices"],
    outputs: ["user_hash_prefix"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-4`), expanded: [], variables: []},
  {
    id: 4,
    body: (choice) => {
const choices = (arr, k) => {
  return [...Array(k)].map((x) => choice(arr));
};
return {choices};
},
    inputs: ["choice"],
    outputs: ["choices"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-5`), expanded: [], variables: []},
  {
    id: 5,
    body: () => {
const choice = (arr) => {
  return arr[Math.floor(Math.random() * arr.length)];
};
return {choice};
},
    inputs: [],
    outputs: ["choice"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

</script>]]></content><author><name>Forest Gregg</name></author><summary type="html"><![CDATA[A trick for finding random Spotify users by searching short prefixes of their user IDs.]]></summary></entry><entry><title type="html">Chicago Policing Force Size and Budgets</title><link href="https://bunkum.us/2024/11/26/chicago-policing-force-size-and-budgets.html" rel="alternate" type="text/html" title="Chicago Policing Force Size and Budgets" /><published>2024-11-26T00:00:00+00:00</published><updated>2024-11-26T00:00:00+00:00</updated><id>https://bunkum.us/2024/11/26/chicago-policing-force-size-and-budgets</id><content type="html" xml:base="https://bunkum.us/2024/11/26/chicago-policing-force-size-and-budgets.html"><![CDATA[<p>I’ve compiled <a href="https://docs.google.com/spreadsheets/d/1kC_G7Qa1WyzKIl8JvmfrNMw0mNcNOQOOeP-qKYhq6QA/edit#gid=0">data on historic budgets and force sizes</a> for the Chicago police department, as well as data on the population of Chicago and index crimes.</p>
<h1>Force size</h1>
<p>The number of sworn-officers per year, i.e. the number of CPD employees who are licensed law enforcement officers with the power to arrest.</p>

<div id="cell-1" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2022-11-21-trends-in-tier-scores-at-cps-selective-enrollment-high-schools/cell-1.png" alt="chart" style="max-width:100%"></div>

<div id="cell-2" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2023-06-04-chicago-population-pyramids/cell-2.png" alt="chart" style="max-width:100%"></div>

<h1>Index Crimes by Sworn officers</h1>
<p>Index crimes are a standardized classification of crimes that <a href="https://en.wikipedia.org/wiki/Uniform_Crime_Reports">police departments voluntarily report to the FBI</a>. We use the total number of index crimes here, except rape which was redefined as a category during this period.</p>

<div id="cell-4" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2022-11-21-trends-in-tier-scores-at-cps-selective-enrollment-high-schools/cell-4.png" alt="chart" style="max-width:100%"></div>

<h1>Appropriated Budget</h1>
<p>This shows the appropriated budget for the Chicago police department. Another interesting measure would be actual spending.</p>

<div id="cell-6" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2024-11-26-chicago-policing-force-size-and-budgets/cell-6.png" alt="chart" style="max-width:100%"></div>

<div id="cell-7" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2024-01-01-michigan-property-tax-simulator/cell-7.png" alt="chart" style="max-width:100%"></div>

<div id="cell-8" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2023-06-04-chicago-population-pyramids/cell-8.png" alt="chart" style="max-width:100%"></div>

<div id="cell-9" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2023-11-01-districting-for-the-chicago-public-schools-elected-board/cell-9.png" alt="chart" style="max-width:100%"></div>

<div id="cell-10" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2024-01-01-michigan-property-tax-simulator/cell-10.png" alt="chart" style="max-width:100%"></div>

<script type="module">
import {define, main, Plot, Inputs, d3} from "/assets/js/reactive-runtime.js";
// Register Plot, Inputs, and d3 as reactive variables so cells that
// reference them resolve through the dependency graph (not runtime builtins).
main.variable().define("Plot", [], () => Plot);
main.variable().define("Inputs", [], () => Inputs);
main.variable().define("d3", [], () => d3);
// Hydrate an inline ${…}: bind its reactive value to its placeholder span,
// updating only that span — never re-render the surrounding (static) prose.
function hydrate(ref, inputs, body) {
  const el = document.querySelector(`[data-reactive="${ref}"]`);
  if (!el) return;
  main.variable({
    pending() {},
    rejected(error) { console.error(error); },
    fulfilled(value) {
      el.replaceChildren(value instanceof Node ? value : document.createTextNode(value == null ? "" : String(value)));
    }
  }).define(null, inputs, body);
}
define(
  {root: document.getElementById(`cell-1`), expanded: [], variables: []},
  {
    id: 1,
    body: (display,Plot,data) => {
display(
  Plot.plot({
    title: "Force size",
    y: { tickFormat: "s", grid: true },
    x: { label: "year" },
    marks: [
      Plot.ruleY([7000]),
      Plot.line(
        data.filter((d) => d["sworn officers"]),
        {
          x: (d) => new Date(d.year, 5),
          y: "sworn officers",
          tip: { format: { x: (d) => d.getFullYear() } },
        },
      ),
    ],
  }),
);
},
    inputs: ["display","Plot","data"],
    outputs: [],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-2`), expanded: [], variables: []},
  {
    id: 2,
    body: (display,Plot,data) => {
display(
  Plot.plot({
    title: "Sworn officers per capita",
    y: { label: "sworn officer per 1,000 Chicagoans", grid: true },
    x: { label: "year" },
    marks: [
      Plot.ruleY([0]),
      Plot.line(
        data.filter((d) => d["chicago population"]),
        {
          x: (d) => new Date(d.year, 5),
          y: (d) => (d["sworn officers"] * 1000) / d["chicago population"],
          tip: { format: { x: (d) => d.getFullYear() } },
        },
      ),
    ],
  }),
);
},
    inputs: ["display","Plot","data"],
    outputs: [],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-4`), expanded: [], variables: []},
  {
    id: 4,
    body: (display,Plot,data) => {
display(
  Plot.plot({
    title: "Index Crimes by Sworn Officer (not including rape)",
    y: { label: "index crimes per sworn officer", grid: true },
    x: { label: "year" },
    marks: [
      Plot.ruleY([0]),
      Plot.line(
        data.filter((d) => d["total index crimes - rape"]),
        {
          x: (d) => new Date(d.year, 5),
          y: (d) => d["total index crimes - rape"] / d["sworn officers"],
          tip: { format: { x: (d) => d.getFullYear() } },
        },
      ),
    ],
  }),
);
},
    inputs: ["display","Plot","data"],
    outputs: [],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-6`), expanded: [], variables: []},
  {
    id: 6,
    body: (display,Plot,data,d3) => {
display(
  Plot.plot({
    title: "Chicago Police Department Appropriations, 2023 dollars",
    marginLeft: 100,
    y: { label: "Appropriations", tickFormat: "$,r", grid: true },
    x: { label: "year" },
    marks: [
      Plot.ruleY([0]),
      Plot.line(
        data.filter((d) => d["Appropriations, 2023 Dollars"]),
        {
          x: (d) => new Date(d.year, 5),
          y: (d) =>
            parseFloat(d["Appropriations, 2023 Dollars"].replace(/[$,]/g, "")),
          tip: {
            format: {
              x: (d) => d.getFullYear(),
              y: (d) => `$${d3.format("0.3s")(d).replace("G", "B")}`,
            },
          },
        },
      ),
    ],
  }),
);
},
    inputs: ["display","Plot","data","d3"],
    outputs: [],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-7`), expanded: [], variables: []},
  {
    id: 7,
    body: (display,Plot,data,d3) => {
display(
  Plot.plot({
    title: "Appropriated Budget for Chicago Police per Capita, 2023 Dollars",
    y: { label: "Appropriations per capita", tickFormat: "$,r", grid: true },
    x: { label: "year" },
    marks: [
      Plot.ruleY([0]),
      Plot.line(
        data.filter(
          (d) => d["Appropriations, 2023 Dollars"] && d["chicago population"],
        ),
        {
          x: (d) => new Date(d.year, 5),
          y: (d) =>
            parseFloat(d["Appropriations, 2023 Dollars"].replace(/[$,]/g, "")) /
            d["chicago population"],
          tip: {
            format: {
              x: (d) => d.getFullYear(),
              y: (d) => `$${d3.format("0.3s")(d).replace("G", "B")}`,
            },
          },
        },
      ),
    ],
  }),
);
},
    inputs: ["display","Plot","data","d3"],
    outputs: [],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-8`), expanded: [], variables: []},
  {
    id: 8,
    body: (display,Plot,data,d3) => {
display(
  Plot.plot({
    title:
      "Chicago Police Department Appropriations per reported index crime, 2023 dollars",
    marginLeft: 50,
    y: {
      label: "Appropriations per index crime",
      tickFormat: "$,r",
      grid: true,
    },
    x: { label: "year" },
    marks: [
      Plot.ruleY([0]),
      Plot.line(
        data.filter((d) => d["Appropriations, 2023 Dollars"]),
        {
          x: (d) => new Date(d.year, 5),
          y: (d) =>
            parseFloat(d["Appropriations, 2023 Dollars"].replace(/[$,]/g, "")) /
            d["total index crimes - rape"],
          tip: {
            format: {
              x: (d) => d.getFullYear(),
              y: (d) => `$${d3.format("0.3s")(d).replace("G", "B")}`,
            },
          },
        },
      ),
    ],
  }),
);
},
    inputs: ["display","Plot","data","d3"],
    outputs: [],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-9`), expanded: [], variables: []},
  {
    id: 9,
    body: (display,Plot,data) => {
display(
  Plot.plot({
    title: "Total Index Crimes",
    marginLeft: 50,
    y: { label: "index crime - rape", grid: true },
    x: { label: "year" },
    marks: [
      Plot.ruleY([0]),
      Plot.line(
        data.filter((d) => d["Appropriations, 2023 Dollars"]),
        {
          x: (d) => new Date(d.year, 5),
          y: "total index crimes - rape",
          tip: { format: { x: (d) => d.getFullYear() } },
        },
      ),
    ],
  }),
);
},
    inputs: ["display","Plot","data"],
    outputs: [],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-10`), expanded: [], variables: []},
  {
    id: 10,
    body: (d3) => {
const data = d3.csv(
  "https://docs.google.com/spreadsheets/d/e/2PACX-1vQIl2G64ISwGQaiN9zcN3ayGynBiast8f_QF7BVJEIa9Llsjd9L1UBQ57zpJIK1GKtO_JPKpa_drpIB/pub?output=csv",
  d3.autoType,
);
return {data};
},
    inputs: ["d3"],
    outputs: ["data"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

</script>]]></content><author><name>Forest Gregg</name></author><summary type="html"><![CDATA[Sworn-officer counts, per-capita budgets, and index crimes for the Chicago Police Department over time.]]></summary></entry><entry><title type="html">Chicago Public School Enrollment Projections</title><link href="https://bunkum.us/2024/11/01/chicago-public-school-enrollment-projections.html" rel="alternate" type="text/html" title="Chicago Public School Enrollment Projections" /><published>2024-11-01T00:00:00+00:00</published><updated>2024-11-01T00:00:00+00:00</updated><id>https://bunkum.us/2024/11/01/chicago-public-school-enrollment-projections</id><content type="html" xml:base="https://bunkum.us/2024/11/01/chicago-public-school-enrollment-projections.html"><![CDATA[<h2 id="kindergarten-though-12th-grade-enrollment-historical-and-projected" tabindex="-1">Kindergarten though 12th Grade Enrollment, Historical and Projected <a class="header-anchor" href="#kindergarten-though-12th-grade-enrollment-historical-and-projected" aria-hidden="true">#</a></h2>
<p>K-12 enrollment has been declining in Chicago Public Schools for
<span data-reactive="0-0">4</span> years: from a high of
<span data-reactive="0-1">319,481</span> students enrolled
in <span data-reactive="0-2">293,033</span> down to <span data-reactive="0-3">10%</span> students
in <span data-reactive="0-4">2%</span>.</p>
<p>Based on <span data-reactive="0-5">2024</span> enrollment and counts of Chicago births
through <span data-reactive="0-6">2023</span>, we project that K-12 enrollment will lose another
<span data-reactive="0-7">16,000</span> students by the <span data-reactive="0-8">2028</span>-<span data-reactive="0-9">2029</span> school year.</p>
<table>
<thead>
<tr>
<th>school year</th>
<th style="text-align:right">projected enrollment (95% credible interval)</th>
</tr>
</thead>
<tbody>
<tr>
<td>2025-2026</td>
<td style="text-align:right"><span data-reactive="0-10">302,000—307,000</span></td>
</tr>
<tr>
<td>2026-2027</td>
<td style="text-align:right"><span data-reactive="0-11">298,000—304,000</span></td>
</tr>
<tr>
<td>2027-2028</td>
<td style="text-align:right"><span data-reactive="0-12">293,000—301,000</span></td>
</tr>
</tbody>
</table>

<div id="cell-1" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2022-11-21-trends-in-tier-scores-at-cps-selective-enrollment-high-schools/cell-1.png" alt="chart" style="max-width:100%"></div>

<h2 id="kindergarten-though-12th-grade-enrollment-by-race-and-ethnicity-historical-and-projected" tabindex="-1">Kindergarten though 12th Grade Enrollment By Race and Ethnicity, Historical and Projected <a class="header-anchor" href="#kindergarten-though-12th-grade-enrollment-by-race-and-ethnicity-historical-and-projected" aria-hidden="true">#</a></h2>
<p>The enrollment decline is caused by two demographic trends. First,
<a href="https://today.uic.edu/uic-report-examines-black-population-loss-in-chicago">Chicago has been losing African Americans of all ages for about twenty years</a>.
Second, the Latino baby boom peaked around 2001 and Latino births have been
falling since.</p>
<table>
<thead>
<tr>
<th>school year</th>
<th style="text-align:right">African American</th>
<th style="text-align:right">Latino</th>
<th style="text-align:right">white</th>
<th style="text-align:right">other</th>
</tr>
</thead>
<tbody>
<tr>
<td>2025-2026</td>
<td style="text-align:right"><span data-reactive="2-0">101,000—104,000</span></td>
<td style="text-align:right"><span data-reactive="2-1">145,000—149,000</span></td>
<td style="text-align:right"><span data-reactive="2-2">33,100—33,900</span></td>
<td style="text-align:right"><span data-reactive="2-3">21,600—22,100</span></td>
</tr>
<tr>
<td>2026-2027</td>
<td style="text-align:right"><span data-reactive="2-4">97,500—100,000</span></td>
<td style="text-align:right"><span data-reactive="2-5">144,000—150,000</span></td>
<td style="text-align:right"><span data-reactive="2-6">32,700—33,900</span></td>
<td style="text-align:right"><span data-reactive="2-7">21,600—22,400</span></td>
</tr>
<tr>
<td>2027-2028</td>
<td style="text-align:right"><span data-reactive="2-8">93,400—96,400</span></td>
<td style="text-align:right"><span data-reactive="2-9">143,000—151,000</span></td>
<td style="text-align:right"><span data-reactive="2-10">32,100—33,500</span></td>
<td style="text-align:right"><span data-reactive="2-11">21,500—22,400</span></td>
</tr>
</tbody>
</table>

<div id="cell-3" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2022-09-22-cps-decline-and-market-share/cell-3.png" alt="chart" style="max-width:100%"></div>

<h2 id="methodology" tabindex="-1">Methodology <a class="header-anchor" href="#methodology" aria-hidden="true">#</a></h2>
<p>Our projections are based on combining two separate projections.</p>
<p>First, we predict the number of students enrolled in 1st grade through 12th
grade based on the number of students enrolled in kindergarten through 11th
grade in the prior year. In the literature on school enrollment projections,
this is called the
<a href="https://nces.ed.gov/programs/projections/projections2021/app_a1.asp">grade progression rate method</a>.</p>
<p>Second, we predict the number of students that will enroll in kindergarten based
on the number of babies born to Chicago residents five-years prior. This is the
called the
<a href="https://nces.ed.gov/programs/projections/projections2021/app_a1.asp">enrollment rate method</a>.</p>
<p>For example, for the <span data-reactive="4-0">2025</span>-<span data-reactive="4-1">2026</span>
school year, we predict the number of 5th graders based on the number of 4th
graders who were actually enrolled in the
<span data-reactive="4-2">2024</span>-<span data-reactive="4-3">2025</span> school year. In order to
make that prediction we need to choose a rate at which 4th graders will turn
into 5th graders. For every demographic group, we calculate the historical
transitions rates for every year-pair we can, and then we choose one of the
historical rates at random. We do this for every grade from 1st through 12th.</p>
<p>Then, for kindergarten, we predict the number of kindergartners who will enroll
by taking the number of babies born in <span data-reactive="4-4">2020</span>, five
years prior to the projected year; and then multiplying that count by an
enrollment rate. We similarly choose a historical enrollment rate at random.</p>
<p>That gives us a projection for all the grades K-12 for the
<span data-reactive="4-5">2025</span>-<span data-reactive="4-6">2026</span> school year. In
order to make a projection for the <span data-reactive="4-7">2026</span>-<span data-reactive="4-8">2027</span> school year, we apply the grade progression
rate method to our <span data-reactive="4-9">2025</span>-<span data-reactive="4-10">2026</span>
projection and the enrollment rate method for the <span data-reactive="4-11">2026</span>-<span data-reactive="4-12">2027</span> kindergarten class.</p>
<p>We keep stepping forward like this through until our final projection year.</p>
<p>This gives us <strong>one</strong> projection trajectory. We then repeat the process
<span data-reactive="4-13">1,000</span> times to get many projection trajectories. This
provides us a range of possible outcomes.</p>
<h3 id="october-2022-updates" tabindex="-1">October 2022 Updates <a class="header-anchor" href="#october-2022-updates" aria-hidden="true">#</a></h3>
<ul>
<li>Use a weighted sample of grade transition and birth to kindergarten
transitions so that transitions from recent years are more likely to be
sampled than transitions from older years.</li>
<li>Don’t sample transitions that had their endpoint in 2020.</li>
</ul>
<h3 id="august-2024-updates" tabindex="-1">August 2024 Updates <a class="header-anchor" href="#august-2024-updates" aria-hidden="true">#</a></h3>
<ul>
<li>Added kindergarten data for 2007 and earlier.</li>
<li>Reincorporated transitions that had their endpoint in 2020</li>
</ul>
<h3 id="october-2024-updates" tabindex="-1">October 2024 Updates <a class="header-anchor" href="#october-2024-updates" aria-hidden="true">#</a></h3>
<ul>
<li>Change sampling weights for choosing a year’s transition rate from ∝
<span data-reactive="4-14"><span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height: 1em; vertical-align: -0.25em;"></span><span class="mopen">(</span><span class="mord"><span class="mord text"><span class="mord">year</span></span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height: 0.2175em;"><span class="" style="top: -2.4559em; margin-right: 0.05em;"><span class="pstrut" style="height: 2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">i</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height: 0.2441em;"><span class=""></span></span></span></span></span></span><span class="mspace" style="margin-right: 0.2222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right: 0.2222em;"></span></span><span class="base"><span class="strut" style="height: 1.0641em; vertical-align: -0.25em;"></span><span class="mord"><span class="mord text"><span class="mord">year</span></span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height: 0.207em;"><span class="" style="top: -2.4559em; margin-right: 0.05em;"><span class="pstrut" style="height: 2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight">0</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height: 0.2441em;"><span class=""></span></span></span></span></span></span><span class="mclose"><span class="mclose">)</span><span class="msupsub"><span class="vlist-t"><span class="vlist-r"><span class="vlist" style="height: 0.8141em;"><span class="" style="top: -3.063em; margin-right: 0.05em;"><span class="pstrut" style="height: 2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mtight">1.5</span></span></span></span></span></span></span></span></span></span></span></span></span> to ∝
<span data-reactive="4-15"><span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height: 0.888em;"></span><span class="mord">2.</span><span class="mord"><span class="mord">5</span><span class="msupsub"><span class="vlist-t"><span class="vlist-r"><span class="vlist" style="height: 0.888em;"><span class="" style="top: -3.063em; margin-right: 0.05em;"><span class="pstrut" style="height: 2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mopen mtight">(</span><span class="mord mtight"><span class="mord text mtight"><span class="mord mtight">year</span></span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height: 0.2052em;"><span class="" style="top: -2.2341em; margin-right: 0.0714em;"><span class="pstrut" style="height: 2.5em;"></span><span class="sizing reset-size3 size1 mtight"><span class="mord mathnormal mtight">i</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height: 0.2659em;"><span class=""></span></span></span></span></span></span><span class="mbin mtight">−</span><span class="mord mtight"><span class="mord text mtight"><span class="mord mtight">year</span></span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height: 0.1944em;"><span class="" style="top: -2.2341em; margin-right: 0.0714em;"><span class="pstrut" style="height: 2.5em;"></span><span class="sizing reset-size3 size1 mtight"><span class="mord mtight">0</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height: 0.2659em;"><span class=""></span></span></span></span></span></span><span class="mclose mtight">)</span></span></span></span></span></span></span></span></span></span></span></span></span> to increase effect of recent
years.</li>
</ul>
<h2 id="forecast-accuracy" tabindex="-1">Forecast Accuracy <a class="header-anchor" href="#forecast-accuracy" aria-hidden="true">#</a></h2>
<p>For each year that we make forecasts, we will record the actual total enrollment
.</p>
<h3 id="july-2022-forecast" tabindex="-1">July 2022 Forecast <a class="header-anchor" href="#july-2022-forecast" aria-hidden="true">#</a></h3>
<table>
<thead>
<tr>
<th>school year</th>
<th>projected enrollment (95% credible interval)</th>
<th>actual enrollment</th>
</tr>
</thead>
<tbody>
<tr>
<td>2022-2023</td>
<td>296,000—307,000</td>
<td>305,703</td>
</tr>
<tr>
<td>2023-2024</td>
<td>283,000—297,000</td>
<td>305,662</td>
</tr>
<tr>
<td>2024-2025</td>
<td>272,000—288,000</td>
<td>307,412</td>
</tr>
<tr>
<td>2025-2026</td>
<td>262,000—279,000</td>
<td></td>
</tr>
</tbody>
</table>
<h3 id="july-2023-forecast" tabindex="-1">July 2023 Forecast <a class="header-anchor" href="#july-2023-forecast" aria-hidden="true">#</a></h3>
<table>
<thead>
<tr>
<th>school year</th>
<th>projected enrollment (95% credible interval)</th>
<th>actual enrollment</th>
</tr>
</thead>
<tbody>
<tr>
<td>2023-2024</td>
<td>291,000—299,000</td>
<td>305,662</td>
</tr>
<tr>
<td>2024-2025</td>
<td>279,000—290,000</td>
<td>307,412</td>
</tr>
<tr>
<td>2025-2026</td>
<td>268,000—280,000</td>
<td></td>
</tr>
<tr>
<td>2026-2027</td>
<td>257,000—269,000</td>
<td></td>
</tr>
</tbody>
</table>
<p>In 2022 and 2023, there was a significant immigration of Venezuelans and other
asylum seekers starting.</p>
<h3 id="august-2024-forecast" tabindex="-1">August 2024 Forecast <a class="header-anchor" href="#august-2024-forecast" aria-hidden="true">#</a></h3>
<table>
<thead>
<tr>
<th>school year</th>
<th>projected enrollment (95% credible interval)</th>
<th>actual enrollment</th>
</tr>
</thead>
<tbody>
<tr>
<td>2024-2025</td>
<td>289,000—302,000</td>
<td>307,412</td>
</tr>
<tr>
<td>2025-2026</td>
<td>276,000—293,000</td>
<td></td>
</tr>
<tr>
<td>2026-2027</td>
<td>264,000—283,000</td>
<td></td>
</tr>
<tr>
<td>2027-2028</td>
<td>253,000—274,000</td>
<td></td>
</tr>
</tbody>
</table>
<h2 id="bootstrap" tabindex="-1">Bootstrap <a class="header-anchor" href="#bootstrap" aria-hidden="true">#</a></h2>

<div id="cell-5" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2022-09-22-cps-decline-and-market-share/cell-5.png" alt="chart" style="max-width:100%"></div>

<div id="cell-6" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2024-11-26-chicago-policing-force-size-and-budgets/cell-6.png" alt="chart" style="max-width:100%"></div>

<div id="cell-7" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2024-01-01-michigan-property-tax-simulator/cell-7.png" alt="chart" style="max-width:100%"></div>

<div id="cell-8" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2023-06-04-chicago-population-pyramids/cell-8.png" alt="chart" style="max-width:100%"></div>

<div id="cell-9" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2023-11-01-districting-for-the-chicago-public-schools-elected-board/cell-9.png" alt="chart" style="max-width:100%"></div>

<div id="cell-10" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2024-01-01-michigan-property-tax-simulator/cell-10.png" alt="chart" style="max-width:100%"></div>

<div id="cell-11" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2025-04-01-dte-outages/cell-11.png" alt="chart" style="max-width:100%"></div>

<div id="cell-12" class="reactive-cell"><img src="https://bunkum.us/assets/snapshots/2023-11-01-districting-for-the-chicago-public-schools-elected-board/cell-12.png" alt="chart" style="max-width:100%"></div>

<div id="cell-13" class="reactive-cell"></div>

<div id="cell-14" class="reactive-cell"></div>

<div id="cell-15" class="reactive-cell"></div>

<div id="cell-16" class="reactive-cell"></div>

<div id="cell-17" class="reactive-cell"></div>

<div id="cell-18" class="reactive-cell"></div>

<h3 id="grade-data" tabindex="-1">Grade Data <a class="header-anchor" href="#grade-data" aria-hidden="true">#</a></h3>
<p>Chicago Public Schools
<a href="https://www.cps.edu/about/district-data/demographics/">publishes data on their student demographics by grade</a>,
and I’ve compiled
<a href="https://docs.google.com/spreadsheets/d/1GFOEXgOrWECqfQEeMVegepn3ysz-vFgst-RmyqInjUA/edit#gid=1346209997">that data into a spreadsheet</a>.</p>
<p>The data has information about pre-school, but right now we are going to just
look at the grade (kindergarten is coded as 0).</p>
<p>Unfortunately, we will also only be considering four demographic groups:
non-Hispanic Blacks, Hispanics, non-Hispanic whites, and non-Hispanic other.
This is because we will be using data from the Illinois Department of Public
health on births and those are the only demographic groups they report.</p>

<div id="cell-20" class="reactive-cell"></div>

<div id="cell-21" class="reactive-cell"></div>

<h3 id="birth-data" tabindex="-1">Birth Data <a class="header-anchor" href="#birth-data" aria-hidden="true">#</a></h3>
<p>I have compiled the
<a href="https://docs.google.com/spreadsheets/d/11puU8gupkp0gjzN_LXMQaSYbhYoJLr5aylaclxhawDw/edit#gid=0">data for Chicago births</a>
from a number source. See
<a href="/2024/10/18/chicago-births-2009-2020.html">this post for details on sources</a>.</p>

<div id="cell-23" class="reactive-cell"></div>

<div id="cell-24" class="reactive-cell"></div>

<div id="cell-25" class="reactive-cell"></div>

<div id="cell-26" class="reactive-cell"></div>

<script type="module">
import {define, main, Plot, Inputs, d3} from "/assets/js/reactive-runtime.js";
// Register Plot, Inputs, and d3 as reactive variables so cells that
// reference them resolve through the dependency graph (not runtime builtins).
main.variable().define("Plot", [], () => Plot);
main.variable().define("Inputs", [], () => Inputs);
main.variable().define("d3", [], () => d3);
// Hydrate an inline ${…}: bind its reactive value to its placeholder span,
// updating only that span — never re-render the surrounding (static) prose.
function hydrate(ref, inputs, body) {
  const el = document.querySelector(`[data-reactive="${ref}"]`);
  if (!el) return;
  main.variable({
    pending() {},
    rejected(error) { console.error(error); },
    fulfilled(value) {
      el.replaceChildren(value instanceof Node ? value : document.createTextNode(value == null ? "" : String(value)));
    }
  }).define(null, inputs, body);
}
hydrate("0-0", ["latest_enrollment_year","school_age_years","d3"], (latest_enrollment_year,school_age_years,d3) => {
return (
latest_enrollment_year - school_age_years.find(d => d.count ===
d3.max(school_age_years.map(d => d.count))).year
)
});
hydrate("0-1", ["d3","school_age_years"], (d3,school_age_years) => {
return (
d3.max(school_age_years.map(d => d.count)).toLocaleString()
)
});
hydrate("0-2", ["school_age_years","d3"], (school_age_years,d3) => {
return (
school_age_years.find(d => d.count === d3.max(school_age_years.map(d =>
d.count))).year
)
});
hydrate("0-3", ["school_age_years","latest_enrollment_year"], (school_age_years,latest_enrollment_year) => {
return (
school_age_years.find(d => d.year ===
latest_enrollment_year && d.race === 'Total').count.toLocaleString()
)
});
hydrate("0-4", ["latest_enrollment_year"], (latest_enrollment_year) => {
return (
latest_enrollment_year
)
});
hydrate("0-5", ["latest_enrollment_year"], (latest_enrollment_year) => {
return (
latest_enrollment_year
)
});
hydrate("0-6", ["latest_birth_year"], (latest_birth_year) => {
return (
latest_birth_year
)
});
hydrate("0-7", ["school_age_years","latest_enrollment_year","latest_birth_year"], (school_age_years,latest_enrollment_year,latest_birth_year) => {
return (
(school_age_years.find(d => d.year === latest_enrollment_year && d.race ===
'Total').count - school_age_years.find(d => d.year === latest_birth_year + 5 &&
d.race === 'Total').count).toLocaleString( undefined, { maximumFractionDigits:
0, maximumSignificantDigits: 2 })
)
});
hydrate("0-8", ["latest_birth_year"], (latest_birth_year) => {
return (
latest_birth_year +
5
)
});
hydrate("0-9", ["latest_birth_year"], (latest_birth_year) => {
return (
latest_birth_year + 6
)
});
hydrate("0-10", ["credible_interval"], (credible_interval) => {
return (
credible_interval(2025, 'Total')
)
});
hydrate("0-11", ["credible_interval"], (credible_interval) => {
return (
credible_interval(2026, 'Total')
)
});
hydrate("0-12", ["credible_interval"], (credible_interval) => {
return (
credible_interval(2027, 'Total')
)
});
define(
  {root: document.getElementById(`cell-1`), expanded: [], variables: []},
  {
    id: 1,
    body: (display,Plot,school_age_years) => {
display(
  Plot.plot({
    color: {
      legend: true,
    },
    marginLeft: 85,
    y: {
      grid: true,
      tickFormat: "2s",
      nice: true,
      label: "K-12 enrollment",
      domain: [0, 420000],
    },
    x: {
      nice: true,
      label: "year",
    },
    marks: [
      Plot.areaY(
        school_age_years.filter(
          (d) => d.race === "Total" && d.type === "projection",
        ),
        {
          x: (d) => new Date(`${d.year.toString()}-09-15`),
          y1: (d) => d.count - d.stdev * 1.96,
          y2: (d) => d.count + d.stdev * 1.96,
          fill: "type",
          fillOpacity: 0.1,
        },
      ),
      Plot.line(
        school_age_years.filter((d) => d.race === "Total"),
        {
          x: (d) => new Date(`${d.year.toString()}-09-15`),
          y: "count",
          stroke: "type",
          tip: true,
        },
      ),
    ],
  }),
);
},
    inputs: ["display","Plot","school_age_years"],
    outputs: [],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

hydrate("2-0", ["credible_interval"], (credible_interval) => {
return (
credible_interval(2025, "African American")
)
});
hydrate("2-1", ["credible_interval"], (credible_interval) => {
return (
credible_interval(2025, "Hispanic")
)
});
hydrate("2-2", ["credible_interval"], (credible_interval) => {
return (
credible_interval(2025, "white")
)
});
hydrate("2-3", ["credible_interval"], (credible_interval) => {
return (
credible_interval(2025, "other")
)
});
hydrate("2-4", ["credible_interval"], (credible_interval) => {
return (
credible_interval(2026, "African American")
)
});
hydrate("2-5", ["credible_interval"], (credible_interval) => {
return (
credible_interval(2026, "Hispanic")
)
});
hydrate("2-6", ["credible_interval"], (credible_interval) => {
return (
credible_interval(2026, "white")
)
});
hydrate("2-7", ["credible_interval"], (credible_interval) => {
return (
credible_interval(2026, "other")
)
});
hydrate("2-8", ["credible_interval"], (credible_interval) => {
return (
credible_interval(2027, "African American")
)
});
hydrate("2-9", ["credible_interval"], (credible_interval) => {
return (
credible_interval(2027, "Hispanic")
)
});
hydrate("2-10", ["credible_interval"], (credible_interval) => {
return (
credible_interval(2027, "white")
)
});
hydrate("2-11", ["credible_interval"], (credible_interval) => {
return (
credible_interval(2027, "other")
)
});
define(
  {root: document.getElementById(`cell-3`), expanded: [], variables: []},
  {
    id: 3,
    body: (display,Plot,school_age_years_race) => {
display(
  Plot.plot({
    color: {
      legend: true,
    },
    marginLeft: 85,
    y: {
      grid: true,
      tickFormat: "2s",
      nice: true,
      label: "K-12 enrollment",
    },
    x: {
      nice: true,
      label: "year",
    },
    facet: {
      data: school_age_years_race,
      x: "race",
    },
    marks: [
      Plot.areaY(
        school_age_years_race,

        {
          x: (d) => new Date(d.year.toString()),
          y1: (d) => d.count - d.stdev * 1.96,
          y2: (d) => d.count + d.stdev * 1.96,
          fill: "type",
          fillOpacity: 0.1,
        },
      ),
      Plot.line(school_age_years_race, {
        x: (d) => new Date(d.year.toString()),
        y: "count",
        stroke: "type",
        tip: true,
      }),
    ],
  }),
);
},
    inputs: ["display","Plot","school_age_years_race"],
    outputs: [],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

hydrate("4-0", ["latest_enrollment_year"], (latest_enrollment_year) => {
return (
latest_enrollment_year + 1
)
});
hydrate("4-1", ["latest_enrollment_year"], (latest_enrollment_year) => {
return (
latest_enrollment_year + 2
)
});
hydrate("4-2", ["latest_enrollment_year"], (latest_enrollment_year) => {
return (
latest_enrollment_year
)
});
hydrate("4-3", ["latest_enrollment_year"], (latest_enrollment_year) => {
return (
latest_enrollment_year + 1
)
});
hydrate("4-4", ["latest_enrollment_year"], (latest_enrollment_year) => {
return (
latest_enrollment_year + 1 - 5
)
});
hydrate("4-5", ["latest_enrollment_year"], (latest_enrollment_year) => {
return (
latest_enrollment_year + 1
)
});
hydrate("4-6", ["latest_enrollment_year"], (latest_enrollment_year) => {
return (
latest_enrollment_year + 2
)
});
hydrate("4-7", ["latest_enrollment_year"], (latest_enrollment_year) => {
return (
latest_enrollment_year +
2
)
});
hydrate("4-8", ["latest_enrollment_year"], (latest_enrollment_year) => {
return (
latest_enrollment_year + 3
)
});
hydrate("4-9", ["latest_enrollment_year"], (latest_enrollment_year) => {
return (
latest_enrollment_year + 1
)
});
hydrate("4-10", ["latest_enrollment_year"], (latest_enrollment_year) => {
return (
latest_enrollment_year + 2
)
});
hydrate("4-11", ["latest_enrollment_year"], (latest_enrollment_year) => {
return (
latest_enrollment_year +
2
)
});
hydrate("4-12", ["latest_enrollment_year"], (latest_enrollment_year) => {
return (
latest_enrollment_year + 3
)
});
hydrate("4-13", ["replicates"], (replicates) => {
return (
replicates.toLocaleString()
)
});
hydrate("4-14", ["tex"], (tex) => {
return (
tex`(\text{year}_i - \text{year}_0)^{1.5}`
)
});
hydrate("4-15", ["tex"], (tex) => {
return (
tex`2.5^{(\text{year}_i - \text{year}_0)}`
)
});
define(
  {root: document.getElementById(`cell-5`), expanded: [], variables: []},
  {
    id: 5,
    body: (school_age_years) => {
const school_age_years_race = school_age_years.filter(
  (d) => d.race !== "Total",
);
return {school_age_years_race};
},
    inputs: ["school_age_years"],
    outputs: ["school_age_years_race"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-6`), expanded: [], variables: []},
  {
    id: 6,
    body: (d3,cps_demo_data,latest_enrollment_year,total_bootstrap) => {
const school_age_years = (() => {
  const race_totals = d3
    .flatRollup(
      cps_demo_data.filter((d) =>
        new Set([
          ...d3.range(0, 13),
          "Full-Day Kindergarten",
          "Half-Day Kindergarten",
        ]).has(d.grade),
      ),
      (v) => d3.sum(v, (d) => d.count),
      (d) => d.year,
      (d) =>
        new Set(["African American", "Hispanic", "white", "Total"]).has(d.race)
          ? d.race
          : "other",
    )
    .map(([year, race, count]) => ({ year, race, count }));

  return [
    ...race_totals.map((d) => ({ ...d, type: "historical" })),
    ...race_totals
      .filter((d) => d.year === latest_enrollment_year)
      .map((d) => ({ ...d, type: "projection", stdev: 0 })),
    ...total_bootstrap,
  ];
})();
return {school_age_years};
},
    inputs: ["d3","cps_demo_data","latest_enrollment_year","total_bootstrap"],
    outputs: ["school_age_years"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-7`), expanded: [], variables: []},
  {
    id: 7,
    body: (d3,grade_bootstrap) => {
const total_bootstrap = (() => {
  const all_student_bootstrap = d3
    .flatRollup(
      grade_bootstrap,
      (v) => d3.sum(v, (d) => d.count),
      (d) => d.year,
      (d) => d.iteration,
    )
    .map(([year, iteration, count]) => ({
      year,
      race: "Total",
      iteration,
      count,
    }));

  const race_bootstrap = d3
    .flatRollup(
      grade_bootstrap,
      (v) => d3.sum(v, (d) => d.count),
      (d) => d.year,
      (d) => d.race,
      (d) => d.iteration,
    )
    .map(([year, race, iteration, count]) => ({
      year,
      race,
      iteration,
      count,
    }));

  return d3
    .flatGroup(
      [...race_bootstrap, ...all_student_bootstrap],
      (d) => d.year,
      (d) => d.race,
    )
    .map(([year, race, draws]) => ({
      year,
      race,
      count: d3.mean(draws.map((d) => d.count)),
      stdev: d3.deviation(draws.map((d) => d.count)),
      type: "projection",
    }));
})();
return {total_bootstrap};
},
    inputs: ["d3","grade_bootstrap"],
    outputs: ["total_bootstrap"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-8`), expanded: [], variables: []},
  {
    id: 8,
    body: (d3,replicates,grade_data,latest_enrollment_year,latest_birth_year,draw_grade_transition,birth_data,draw_kindergarten_transition) => {
const grade_bootstrap = (() => {
  let bootstrap = [];
  for (const i of d3.range(replicates)) {
    // use the last year of cps enrollment data as the base year for
    // forecasts
    let previous_year = grade_data.filter(
      (d) => d.year === latest_enrollment_year && d.race !== "Total",
    );
    for (const projected_year of d3.range(
      latest_enrollment_year + 1,
      latest_birth_year + 5 + 1,
    )) {
      const grade_bootstrap = previous_year
        .map((d) => ({
          iteration: i,
          year: projected_year,
          race: d.race,
          grade: d.grade + 1,
          count: d.count * draw_grade_transition(d.grade, d.race),
        }))
        .flat()
        .filter((d) => d.count);
      const kindergarten_bootstrap = birth_data
        .filter((birth) => birth.year === projected_year - 5)
        .map((birth) => ({
          iteration: i,
          year: projected_year,
          race: birth.race,
          grade: 0,
          count: birth.count * draw_kindergarten_transition(birth.race),
        }));
      if (kindergarten_bootstrap.length < 4) {
        throw `Don't have enough data on births for ${projected_year - 5}`;
      }
      const projection = [...grade_bootstrap, ...kindergarten_bootstrap];
      bootstrap = [...bootstrap, ...projection];
      previous_year = projection;
    }
  }
  return bootstrap;
})();
return {grade_bootstrap};
},
    inputs: ["d3","replicates","grade_data","latest_enrollment_year","latest_birth_year","draw_grade_transition","birth_data","draw_kindergarten_transition"],
    outputs: ["grade_bootstrap"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-9`), expanded: [], variables: []},
  {
    id: 9,
    body: (grade_transitions,sampling_weights,weighted_sample) => {
const draw_grade_transition = (grade, race) => {
  const transitions = grade_transitions.get(grade)?.get(race);
  if (transitions) {
    const weights = sampling_weights(transitions.length);
    return weighted_sample(weights, transitions);
  }
};
return {draw_grade_transition};
},
    inputs: ["grade_transitions","sampling_weights","weighted_sample"],
    outputs: ["draw_grade_transition"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-10`), expanded: [], variables: []},
  {
    id: 10,
    body: (d3,grade_lag) => {
const grade_transitions = (() => {
  const transitions = d3.group(
    grade_lag,
    (d) => d.grade,
    (d) => d.race,
  );
  for (const [grade, map] of transitions) {
    for (const [race, lags] of map) {
      map.set(
        race,
        lags.map((d) => d.y / d.x),
      );
    }
  }
  return transitions;
})();
return {grade_transitions};
},
    inputs: ["d3","grade_lag"],
    outputs: ["grade_transitions"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-11`), expanded: [], variables: []},
  {
    id: 11,
    body: (grade_data,d3) => {
const grade_lag = grade_data
  .map((d) => ({
    grade: d.grade,
    race: d.race,
    year: d.year,
    x: d.count,
    y: grade_data.find(
      (e) =>
        e.year === d.year + 1 && e.grade === d.grade + 1 && e.race === d.race,
    )?.count,
  }))
  .filter((f) => f.y)
  .sort((a, b) => d3.ascending(a.year, b.year));
return {grade_lag};
},
    inputs: ["grade_data","d3"],
    outputs: ["grade_lag"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-12`), expanded: [], variables: []},
  {
    id: 12,
    body: (birth_kindergarten_transitions,sampling_weights,weighted_sample) => {
const draw_kindergarten_transition = (race) => {
  const transitions = birth_kindergarten_transitions.get(race);
  if (transitions) {
    const weights = sampling_weights(transitions.length);
    return weighted_sample(weights, transitions);
  }
};
return {draw_kindergarten_transition};
},
    inputs: ["birth_kindergarten_transitions","sampling_weights","weighted_sample"],
    outputs: ["draw_kindergarten_transition"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-13`), expanded: [], variables: []},
  {
    id: 13,
    body: (d3,birth_lag) => {
const birth_kindergarten_transitions = (() => {
  const transitions = d3.group(birth_lag, (d) => d.race);
  for (const [key, value] of transitions) {
    transitions.set(
      key,
      value.map((d) => d.y / d.x),
    );
  }
  return transitions;
})();
return {birth_kindergarten_transitions};
},
    inputs: ["d3","birth_lag"],
    outputs: ["birth_kindergarten_transitions"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-14`), expanded: [], variables: []},
  {
    id: 14,
    body: (birth_data,grade_data,d3) => {
const birth_lag = birth_data
  .map((birth) => ({
    year: birth.year,
    race: birth.race,
    x: birth.count,
    y: grade_data.find(
      (e) =>
        e.year === birth.year + 5 && e.race === birth.race && e.grade === 0,
    )?.count,
  }))
  .filter((d) => d.y)
  .sort((a, b) => d3.ascending(a.year, b.year));
return {birth_lag};
},
    inputs: ["birth_data","grade_data","d3"],
    outputs: ["birth_lag"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-15`), expanded: [], variables: []},
  {
    id: 15,
    body: (d3) => {
const sampling_weights = (N) => {
  const weights = d3.range(1, N + 1).map((i) => 2.5 ** i);
  const C = d3.sum(weights);
  return weights.map((i) => i / C);
};
return {sampling_weights};
},
    inputs: ["d3"],
    outputs: ["sampling_weights"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-16`), expanded: [], variables: []},
  {
    id: 16,
    body: (d3,grade_data) => {
const latest_enrollment_year = d3.max(grade_data.map((d) => d.year));
return {latest_enrollment_year};
},
    inputs: ["d3","grade_data"],
    outputs: ["latest_enrollment_year"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-17`), expanded: [], variables: []},
  {
    id: 17,
    body: (d3,birth_data) => {
const latest_birth_year = d3.max(birth_data.map((d) => d.year));
return {latest_birth_year};
},
    inputs: ["d3","birth_data"],
    outputs: ["latest_birth_year"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-18`), expanded: [], variables: []},
  {
    id: 18,
    body: () => {
const replicates = 1000;
return {replicates};
},
    inputs: [],
    outputs: ["replicates"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-20`), expanded: [], variables: []},
  {
    id: 20,
    body: (d3,cps_demo_data) => {
const grade_data = [
  ...d3
    .flatRollup(
      cps_demo_data,
      (v) => d3.sum(v, (d) => d.count),
      (d) => d.year,
      (d) =>
        new Set(["African American", "Hispanic", "white", "Total"]).has(d.race)
          ? d.race
          : "other",
      (d) => d.grade,
    )
    .map(([year, race, grade, count]) => ({ year, race, grade, count }))
    .filter((d) => new Set(d3.range(13)).has(d.grade)),
  ...d3
    .flatRollup(
      cps_demo_data.filter((d) =>
        new Set(["Full-Day Kindergarten", "Half-Day Kindergarten"]).has(
          d.grade,
        ),
      ),
      (v) => d3.sum(v, (d) => d.count),
      (d) => d.year,
      (d) =>
        new Set(["African American", "Hispanic", "white", "Total"]).has(d.race)
          ? d.race
          : "other",
    )
    .map(([year, race, count]) => ({ year, race, grade: 0, count })),
];
return {grade_data};
},
    inputs: ["d3","cps_demo_data"],
    outputs: ["grade_data"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-21`), expanded: [], variables: []},
  {
    id: 21,
    body: (d3) => {
const cps_demo_data = d3.csv(
  "https://docs.google.com/spreadsheets/d/e/2PACX-1vSlDJgyBRmDGBdhVi_fWU6bxprkLZrKrW2YNvGW1hVToXRz9kWQvAPM2UVh28sGMjqfL_1nBNUrjHbl/pub?gid=1346209997&single=true&output=csv",
  d3.autoType,
);
return {cps_demo_data};
},
    inputs: ["d3"],
    outputs: ["cps_demo_data"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-23`), expanded: [], variables: []},
  {
    id: 23,
    body: (birth_data_raw) => {
const birth_data = [
  ...birth_data_raw
    .map((d) => [
      {
        year: d.year,
        race: "African American",
        count: d["non-hispanic black"],
      },
      { year: d.year, race: "Hispanic", count: d.hispanic },
    ])
    .flat(),
  ...birth_data_raw
    .filter((d) => d["non-hispanic white"])
    .map((d) => [
      { year: d.year, race: "white", count: d["non-hispanic white"] },
      { year: d.year, race: "other", count: d["non-hispanic other"] },
    ])
    .flat(),
].filter((d) => d.count);
return {birth_data};
},
    inputs: ["birth_data_raw"],
    outputs: ["birth_data"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-24`), expanded: [], variables: []},
  {
    id: 24,
    body: (d3) => {
const birth_data_raw = d3.csv(
  "https://docs.google.com/spreadsheets/d/e/2PACX-1vQIqfBxFiIipgTjASaQObMUzZ8CkMuDDJA40GSr3Ajfc9ObkJRXqIElJHYFfSjuPqv-nvhrfJCWz7bO/pub?gid=0&single=true&output=csv",
  d3.autoType,
);
return {birth_data_raw};
},
    inputs: ["d3"],
    outputs: ["birth_data_raw"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-25`), expanded: [], variables: []},
  {
    id: 25,
    body: (school_age_years) => {
const credible_interval = (year, race) => {
  const target_year = school_age_years.find(
    (d) => d.year === year && d.race === race,
  );
  return `${(target_year.count - target_year.stdev * 1.96).toLocaleString(
    undefined,
    {
      maximumFractionDigits: 0,
      maximumSignificantDigits: 3,
    },
  )}—${(target_year.count + target_year.stdev * 1.96).toLocaleString(
    undefined,
    {
      maximumFractionDigits: 0,
      maximumSignificantDigits: 3,
    },
  )}`;
};
return {credible_interval};
},
    inputs: ["school_age_years"],
    outputs: ["credible_interval"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

define(
  {root: document.getElementById(`cell-26`), expanded: [], variables: []},
  {
    id: 26,
    body: () => {
// Inlined from @nstrayer/javascript-statistics-snippets.
function weighted_sample(weights, values) {
  const random_val = Math.random();
  let cumulative_prob = 0,
    i;
  for (i = 0; i < weights.length; i++) {
    cumulative_prob += weights[i];
    if (cumulative_prob > random_val) break;
  }
  return values ? values[i] : i;
}
return {weighted_sample};
},
    inputs: [],
    outputs: ["weighted_sample"],
    output: null,
    autodisplay: false,
    autoview: false,
    automutable: false
  }
);

</script>]]></content><author><name>Forest Gregg</name></author><summary type="html"><![CDATA[A cohort-survival bootstrap forecast of Chicago Public Schools K-12 enrollment, overall and by race/ethnicity, with credible intervals.]]></summary></entry></feed>