building in biology & ml. bme + cs @ umn. Biomedical engineering + computer science at UMN.
minneapolis, mn
recently: just buy singles? · pack-ev
TM-Env
I built TM-Env, a Terra Mystica environment for multi-agent reinforcement learning, on a rules engine that replays 3,552 of 3,553 tournament games move for move. Legal-action masks come from the engine itself, and PettingZoo and Gymnasium API checks pass at 2–5 players.
[code]ercot-nodal
An eight-bus model of ERCOT dispatch, built to see how locational prices form and where batteries are worth siting. I checked every bus price against the finite-difference cost of serving more load there, and found the cheapest bus is the worst place for storage.
[model]GopherGuessr
A GeoGuessr-style game for the UMN campus, built by a student team. I worked on the React frontend of the gameplay loop: the guess map, the results and end-of-game dialogs, and the home page's stats carousel.
[play] [code]pack-ev
Prices every card a Magic: The Gathering booster can contain, weighted by Wizards of the Coast's published slot odds, and compares the result with the sealed pack's price for 39 sets and 94 booster products, week by week since June 2021. The full value distribution of a pack comes from convolving per-slot distributions on a one-cent grid. Weekly TCGplayer history lives in a Parquet store that is backfilled once per product and then updated incrementally, so refreshing every set takes about five minutes. Finds that Collector Boosters are a coin flip at launch and worth a median 76% of their sealed price by year two, while Play Boosters sit at break-even only if bulk commons can actually be sold.
[dashboard] [blog] [code]bgelo
Glicko-1 rating engine for multiplayer board games, decomposing n-player results into 1v1 matchups weighted by game complexity, relative familiarity, elo spread, and duration. Algorithm auto-tuned by backtester that scores pre-play forecasts to tune weighting mechanisms. Currently forecaster accurately calls 62.5% of decisive pairwise matchups against a 50% baseline. Accuracy should improve with data, currently very low sample-size (n<10 for most games).
[stats] [blog]openbsl
Reads Biopac Student Lab physiology recordings (ECG, pulse, heart sounds) without the proprietary BSL software. Exports them to CSV and builds a static browser viewer that reproduces BSL's measurement workflow: drag-select a region, read off Delta T, BPM and peak-to-peak amplitude, label it, and the lesson's data report tables fill themselves in. Beat detection is tested against synthetic ECG with known ground truth. Automatic P/T-wave delineation was benchmarked on real student leads and rejected: on a noisy lead its beat-to-beat jitter was as large as the effects the lab measures. Lessons 6 and 16 are automated where the signal allows it: the mean electrical axis from Lead I and III QRS amplitudes, and Korotkoff sounds found by their lock to the heartbeat rather than their loudness, validated on synthetic recordings with known pressures.
[case study] [code] [blog]dr.active
Predicts protein-ligand binding affinity (pChEMBL) from a SMILES string and a target name, over 55,176 ChEMBL activity records across six well-populated, mechanistically different targets. A random forest on RDKit descriptors and Morgan fingerprints reaches RMSE 0.648 log units on a random split and 0.761 on a scaffold-disjoint one, against 1.18 for a per-target-mean baseline. The experimental message-passing GNN loses on every split, and the frequency-weighted scaffold split refutes the one thing it looked good at: its apparent robustness to held-out chemotypes was a property of the split, not the model. Two independent scaffold splits agree to 0.02 log units, so the honest reading is that an unseen scaffold costs about 0.12 log units — and that 0.76 is still a factor of six in concentration, good for ranking rather than for predicting a number. The forest's single most important feature is a target indicator, which is most of what the baseline already captures.
[code]Ripple
Minnehack 2025 Finalist. Built for the theme “finding community,” users complete community-driven challenges with peer-based verification to climb local leaderboards.
[demo] [code]BioScrolls
Mines PubMed abstracts on five neurological diseases for gene-disease, drug-disease and gene-drug associations. A BioBERT encoder fine-tuned on BioRED reaches 0.845 micro-F1 on entity recognition and 0.655 typed F1 on relation extraction on the held-out test split, against 0.452 for a co-occurrence baseline. Extractions over 3,033 abstracts become a time-indexed SQLite knowledge graph of 8,921 typed, dated edges, queryable by entity, year and per-year trend. 520 of those abstracts are non-English-language articles, mined through the English titles and abstracts PubMed supplies for them — the models themselves are English-only. End to end, from raw text to normalised entity ids, it scores 0.275 F1: dictionary normalisation, not extraction, is the bottleneck.
[code]dr.optimus
A MolDQN-style double-DQN agent that edits molecular graphs under valence-checked RDKit actions, reproducing Zhou et al. 2019 on the three standard benchmarks and reporting where the reproduction falls short. On QED a graph-neural-network Q-head reaches final-episode top-three 0.822/0.811/0.805 against the paper's 0.934, beating both a random-edit baseline (0.211) and a fingerprint MLP given four times the episodes — one seed per configuration, so suggestive rather than established. Penalized logP fails and the failure is the interesting part: with terminal-only reward the policy is beaten by its own random baseline, and paying the per-step change in objective instead recovers about half the gap, which locates the cause in reward scale over a 40-step horizon rather than in the agent. On the similarity-constrained task it loses to random editing outright (+0.92 mean improvement against +1.38, at 27.8% of molecules improved against 36.4%) and tripling the training budget does not help; its greedy policy degenerates to making no edit at all.
[code]folduzz
A DCGAN over 64x64 CA-CA distance matrices cut from 735 PDB structures, kept because of what measuring it carefully showed. It learns real geometry: neighbouring residues land 4.24 A apart against a true 3.81, triangle violations fall to 2.54% where a matrix with the identical distance histogram breaks them on 9.57%, and contact density, contact order and radius of gyration all sit within 10% of the real distributions. It is not memorising either — samples are further from the training set than real held-out windows are. But the output is not a valid distance matrix: symmetry is never learned, 19.5% of the eigenvalue mass is negative, and after MDS embedding only 30.1% of consecutive residues sit at a plausible bond length against 97.6% for real windows. Scoring every checkpoint shows everything geometric plateaus by epoch 60 while only the distance histogram keeps improving — the adversarial objective never asks for geometric consistency, so training longer cannot fix it.
[code]just buy singles?Oct 9, 2026 · 9 min
Five years of TCGplayer history to explain why opening sealed is a bad idea
openbsl: reading physiology lab data without the lab's softwareSep 29, 2026 · 13 min
Parsing Biopac Student Lab recordings, rebuilding the measurement workflow in a browser, why automatic ECG delineation lost to a mouse, and where automation did work: the heart's electrical axis and Korotkoff sounds
bgelo: an elo engine for board game nightAug 3, 2026 · 15 min
A component-by-component walkthrough of the pod's rating engine — pairwise Glicko-1, context weights, calibration, and every custom decision in between
the full archive lives here.
,>=+:
{c7984z"
!2CX0ZY8\.
/29YXXC4ol?!
}157763v^"(|u/=^
<o35541v? ,~l)x}=^
'fc333cur- -!llf=!`
~ny221ox|_ ,!?f|f+:.
=xoccyvj\_ ,!]z)l>_
?zuyyoj|}^ "+|n\(-_
*jvuujti(?~, ~?|n}}"'
>rxvvnzrill\[:' <}lu/]'
;iznnnzf|{<]ft)+^. ?{jx?*.
_}rzxxzfi{^ ^<|xf\<^ '`{\rj=!:[//[^
.?ljzxzfl\>. ~[fnf\~, _:izfxvuuv=
;)rjzzft)?, _<\uuz]:` ,!}juoc111o|
,{lfjjft|};.```````;/uc1l[=]})rxc4568897753z_
`:<[/ifxoc3678CJJQQYYYYYYYYXZXXXXXXX0ZZ0OZOZO0O000ZZXUQJ04(.
,+)z28LYZmwdkaoXNXMRRR##%%%%%%%%MMMM%%%%####M#MMXXEhabdqmOXC1/_
'*f39JXOqdkoXNM%%MWBW&&&&@@@@$$$$$$$@@@@@&&&BWBMM##NNohbp09ct*,
*u5LYZmqdkaaoEEXENXNXoEobdddddddkhhhhdpqwm000ULC053yvr)]<"
(28CY0OOmmOC5ur(]=>/x4oi*_ `?|trfrt|}~
?17LJYYYLj+' .>\x1z/>` ~(lrffti\*`
f38LLL93n+:. '?\nui]^. _{irffrl){^
x378863j!]z)l>:. -?)nj)<,'=|trfft|\>
)c4553cz" "+{zi(-_ :[|jl]*)trffri(*'
!u2442cn\. ~?|n}}"' !]|liilrfrl)],
\u122cuj* <}lu/]' _!{l)|rrt|}_
'tucccoxt+ ?{jx?*. =itjffti/"
"tvoyouj|= '`{\jx+<. +lrfffti/`
_ixuoui)|*:' '`\lzt<! =trfffri]
,)zvuvxrtit}+: "^))t|~: {trfffrl>
'}fxnvxjl\{/lr}=_ ;~lri]-, ,lrrfffr(
=tzxnxjt(<`^[|zf]<` '++zli]_/trjzzzr,
:)fzxzjt|[, '~\rut[>. '+=nfrznvvnj'
,{tjzzjr|}: ,[)nol]^ _=tnuyycco(
<|rjjjri\+' :/t1y|*_"-!={)ru134675652x,
_:>*]/)irjznooo256678987644333433788CJCQJQQYYYXUYYYQJC86i`
^=(fy60QUOmqdkhaaEXXXNNNNNRRRRRRRMMMMNNNMNNNXXXoohhbdpwm0UJ7l-
!)o6LYZwpbhENNR#MMWBB&&&@@@@@$$$$$$$$@@@&&&BBWW%%#MXXakpq0Lci<.
<x50QZOqbkaoXXNNRR####%#####%%%%#RRRRRRRRRMMMoaadm0JL5yx|]!`
.\28JY0OwwqqwUL3yxrf208yl*;;;;;;;:<|tfjjfl)]^`.
[18LQUUUQc)<` _>|ocx[~' ,{itffrl){-
+48LCC05l"' "=tvo|+: .+|trfrt|/<.
~luoz{=}jx?*. :]lnj}!, -\lrffri(?,
.{\jx+<. `~{tzi*~}irffrl|}-
'`\lzt<! `!\|fiilrffti\~
"^))t|~: _+/|i|lfti(<
;~llt|:, ^\ltjfrl)+
;<zli]_' _\tfffrt)<
'+=nl(?_' ,)rrffft|~
`??nr}!` "irfffft),
^}/o)}! <trfffft+
^}/u)[".trrfjjf\
,<))xzc564yx|
']5JXZXC3]
_oJX0XCz'
.+)i(!