FIELD NOTES / 2026

HUMAN × AI
OBSERVATION LOG

LOG ENTRY / post

水槽と熱帯魚の生態系シミュレーター「アクアラボ」を作った

水槽と熱帯魚の生態系シミュレーター「アクアラボ」を作った

水槽の中で起きていることを、できるだけごまかさずに一本の収支で解いてみたくなりました。

→ アクアラボ

ブラウザだけで動きます。スマホでも動きますが、3Dは重めなのでPC推奨です。調べたこと・決めたこと・失敗したことは別のページに書きました。

→ アクアラボ実装ノート

どういうものか

水槽・底床・フィルター・ヒーター・照明・餌・水道水・室温を機材DBから選んで魚を入れると、時間が進みます。倍速で1年でも回せます。

  • 窒素は台帳で閉じている。 餌として入った窒素が、魚の体・アンモニア・亜硝酸・硝酸・デトリタス・生物膜・水草・エビ・換水で出た分のどこにあるか、半年回しても相対残差が 1e-9 以下で一致します。反応網は正値性と質量保存を両立させる修正 Patankar–Euler で解いています(普通の陽的オイラーだと、濃度が負になるかプールが漏れるかのどちらかになります)
  • 計器が三層ある。 真値、試薬(テトラ6in1・API などの段階丸めと発色待ち)、電子計器(Seneye・pHペンのドリフトと校正・TDS)。「試薬では0.25と出ているが真値は0.4」のようなずれが普通に起きます
  • 濾過装置の中身がある。 外掛け・上部・外部・投げ込み・スポンジ・底面・サンプを実機の構造で作り、濾材を層ごとに通水順に重ねます。空塔速度・接触時間・目詰まり・洗浄まで入れてあります。濾材を水道水で洗うと出るのはアンモニアではなく亜硝酸のスパイクだ、というのはやってみて分かりました
  • 水流を解いている。 水槽内を約7,000セルの格子で非圧縮流体として解き、軌跡付きの粒子で見せます。外掛け1台の60cm水槽で遠端が淀む、あの実感がそのまま出ます
  • 魚は骨格から泳ぐ。 剛体の頭蓋+椎骨チェーン(スズメダイ科26・ニザダイ科22は科の標準形質、オスカーとピラニアは科の範囲からの推定)+受動的な尾鰭。重心は断面の面積分布と鰾の位置から積分して出しています(吻端から全長の 0.34〜0.38、文献値と整合)。曲がった体が抵抗の異方性で自分の中心線に沿って滑るので、旋回は「頭が先に切り込み、体幹が頭の軌跡を追う」形になります。剛体をその場で回しているわけではありません
  • 一期一会の個体差。 導入のたびに L∞・成長速度・食欲・基礎代謝・大胆さ・気性・模様を乱数で引きます。CV は文献値のあるものは文献値、無いものは推定と明記
  • 海水(リーフ)版もある。 塩分・炭酸系・ホウ酸・アラゴナイト飽和度を解き、プロテインスキマー・ドージング・ライブロックの脱窒・サンゴの石灰化と白化まで。海水の硝化群集は淡水と別物(コマモックスが居ない、AOA と AOB が拮抗)なので、文献で作り直しました
  • 水草・藻・ヌマエビ(v0.8)。 水草19種の光合成は P–I 曲線で、CO2 は飽和値を下げるが初期勾配は変えない、という形にしてあります。単純に光×CO2 を掛けると弱光域を 1/3 に過小評価して、低光量水槽の陰性草が枯れてしまいます。これが実装中に見つかった一番大きい修正でした。藻は5種(珪藻・緑スポット・糸状・黒髭・ラン藻)で付着面積に頭打ち。エビは8節の外骨格で矢状面1自由度、全屈曲で25°(Crangon の実測値)、歩脚は10本のうち常に3本以上接地。ヤマトヌマエビは汽水が要るので淡水では殖えません

作りながら分かったこと

  • アルカリ度の正味消費は 3.57 mg CaCO3/mg N。 趣味界でよく見る 7.14 は塩化アンモニウムを投入するフィッシュレスサイクリングでだけ成り立つ数字で、魚の排泄はアンモニア化の時点で半分戻してきます。それでも成魚オスカーの90cmで 0.4 dKH/日 は減ります
  • 境界層の物質移動上限を入れないと濾過が効きすぎる。 生物膜の反応速度だけで計算すると1パス除去率が 99% になってアンモニアが 0.001 mg/L まで落ちます。kD ≈ 1.3 m/日 を入れると 40% 程度になって現実的な値になりました
  • 放置水槽の主な死因は NH3 ではない。 「放置したあとの大量換水でアンモニア中毒」は、換水がアンモニアも希釈するのでモデル上は思ったほど致死的になりません。実際に効いてくるのは KH が枯れて pH が落ちたあとの遊離亜硝酸と慢性ストレスでした。正直にそう見せています
  • 東京の水道水(KH 2.5)で KH を補給せずにサイクリングすると10日で pH 6 に落ちて止まります。 趣味界で言われる「サイクリング中の pH クラッシュ」がそのまま出ます

直した恥ずかしいやつ

  • ソイルのアンモニア溶出が窒素を無から生成していた(v0.7 以前からずっと)
  • 藻の被食をエビ側と藻側で二重に引いていた
  • 孵化のときに稚エビの体重を母体から引く量がずれていた
  • 進行波の振幅包絡を全長基準の式に体長を入れていて、尾柄の振幅が6割増しになっていた
  • 過飽和した酸素が抜けずに飽和度 350% まで上がった(葉に泡がつく pearling を脱気として入れて 150% で頭打ちにした)

いずれも台帳の残差が教えてくれました。収支が閉じるように作っておくと、こういう間違いが数値として浮いてきます。

追記(v0.8.3):眺めるためのモードと、手で撒く給餌

計器を全部消して眺めるモードと、水面をタップして餌を撒く操作を足しました。

ギャラリーモード。 ヘッダも計器盤もゲージも消えて、3Dだけの全画面になります。カメラは10種類のショットを自動で切り替えます。全景、正面をゆっくり横に流す、角から回り込む、水草や流木に寄る、底床すれすれから水面を見上げる、前縁から覗き込む、1匹を追う、群れの重心を回る、エビに寄る。画角も19〜44°の間で振ってあるので、レンズを替えながら撮っている感じになります。時間は実時間に落ちるので、魚は普通の速さで泳ぎます。

カメラは必ずガラスの外に留めています。現実に水槽を見るのと同じ視点というのが第一ですが、水中に入れると水中シェーダの光路長計算が破綻するという実装上の理由もあります。奥の被写体には寄れない、というのは実物と同じです。

手で撒く給餌。「餌をあげる」で構えてから、水面をタップするかドラッグして撒きます。粒は沈み、魚が1粒ずつ見つけて食べます。ペレットは沈み、フレークは40秒ほど浮いてから沈み、フィルターの取水口に入った分は物理濾過され、底に落ちた分はデトリタスになります。エビは落ちた粒に歩いて寄ってきます。

反応には既存の個体差がそのまま出ます。大胆な個体はほぼ即座に、臆病な個体は数秒ためらってから寄ります。見つけられる距離も食欲と大胆さとストレスで変わります。突っ込む速さも違うので、大胆な個体が群れの隊列から抜けて先に到着します。食べた個体から順に満腹になって興味を失っていくのも、その場で見えます。31匹のネオンテトラで60秒観察したら、10匹が食べて、21匹は何も食べませんでした。

窒素はプールを増やさずに閉じました。撒いた時点で乾物ぜんぶを懸濁デトリタスとして系に入れ、魚が1粒食べたらそこから胃へ移す。底に落ちたら難分解デトリタスへ、フィルターに吸われたら濾過槽トラップへ。全部が既存のプール間の移動なので、台帳はそのままです。3水槽で3種類の餌を撒いて食べ切らせ、33時間進めても相対残差は1e-13以下でした。

直したもの

  • 全景ショットが必ず水槽をはみ出した。 見かけの大きさを決めるのは中心までの距離ではなく前面ガラスまでの距離です。奥行きの半分を足し忘れていました
  • 魚が餌の周りに集まるのに、ほとんど食べない。 距離を重心から測っていました。体長の0.45倍ぶん遠く見積もることになるので、2cmのテトラでは口が届く距離より大きく、原理的に到達できません。吻端から測るように直しました
  • 底に落ちた粒の3cm上でホバリングし続ける。 縦方向の操舵を遊泳層の好みが握っていました。中層を泳ぐ種は、底の粒を認識していても降りる駆動を持っていなかった
  • 魚を追うと画面の上半分が蓋になる。 構図の中心を、魚そのものではなく水柱の中に引き戻した高さに
  • 群れのショットが引きすぎる。 広がりを最大距離で測っていて、はぐれた1匹に引っ張られていました

食べないほうの2件は、「餌を食べる」機能の側ではなく、既存のモデル(重心の定義、遊泳層の好み)との接続部で起きています。窒素収支のような検証手段がない領域だと、こういう間違いは「食べない」という症状としてしか出てきません。

詳しくは実装ノートの§15 眺めるためのカメラと§16 手で撒く給餌に書きました。

追記2(v0.8.8):成長・口・地形

続けて5つ足しました。どれも「見た目を良くする」ところから入って、モデル側の間違いが出てきた回でした。

サンゴの成長を石灰化から導いた

いちばん直すべきだったのはここです。それまでサンゴの成長は size を経験式で増やしていて、アルカリ度の収支とは別の式になっていました。同じ「サンゴが石灰化する」という一つの現象に式が二本あって、片方を変えても他方が追従しない。この水槽の主題からすると、これはおかしい。

いまは石灰化量を一度だけ計算して、そこから骨格質量を積み、質量から径を出します。アルカリ度の消費も同じ値から出ます。

ところが実装して1年回したら、ミドリイシが25cmから28cmにしかならず、アルカリ度の消費も0.2 dKH/日と低い。SPS主体のリーフだと0.5〜1.5 dKH/日が普通です。原因は面積でした。石灰化速度は「生きた組織の面積あたり」の値なのに、投影面積(真上から見た面積)を掛けていた。枝状の群体の実表面積は投影面積の3〜4倍あります。

表面積指数を入れたら、1年でミドリイシ25→36.8cm、石灰化7.1 kg/年 = 19 g/日 ≈ 0.7 dKH/日。実測レンジに入りました。

成長が遅いことと、アルカリ度の消費が少ないことは、別々の症状ではなく同じ一つの誤りでした。 式を二本持っていたときは、片方を見て「成長が遅いから係数を上げよう」と調整できてしまいます。一本にまとめた結果、本当の原因が浮いてきました。

ソフトコーラルとイソギンチャクは骨格がないので組織量で扱います。調子が悪いと組織を吸収して縮みます。イソギンチャクがしぼむのはこれです。イソギンチャクは歩きます。 光が合わないと8時間ごとに移動して、暗ければ浅い方へ向かう。PAR 45の暗い照明で底に置いたら、18日かけて水面直下まで登りました。餌を取れている状態が45日続くと分裂します。

水草はランナーで子株が出るようになりました。ヘアーグラス7日からエキノドルス42日まで。子株の生物量は母株から取るので、窒素は移動するだけです。

魚の口と、吸引摂餌

口は色を暗くした線でしかなかったので、実体のある構造にしました。下顎が顎関節まわりに下制して、上顎が前へ突き出し、口腔が横に張って、遅れて鰓蓋が張る。吸引摂餌の順序をそのまま並べています。

捕食も「届いたら消える」をやめました。吸い込み流は口径のあたりで最大、そこから距離の3乗で落ちます。粒が口へ吸い寄せられてから消えます。 空振りもします(成功率63%)。

ここで2つ踏みました。ひとつは距離を重心から測っていたこと。体長の0.45倍ぶん遠く見積もることになるので、2cmのテトラでは口が届く距離より大きく、原理的に到達できません。もうひとつは縦方向の操舵を遊泳層の好みが握っていたこと。中層を泳ぐ種は、底の粒を認識していても降りる駆動を持っていませんでした。

エビの形

断面がほぼ真円で「筒」に見えていました。上下で別の式にして側板を垂らし、額角を薄い刃に、尾扇に尾節と尾肢2対、眼柄と触角鱗片を足して、節の端をふさぎました。横から見て尾扇が薄いのは正しい挙動です(背腹に扁平なので、上から見ると扇になります)。

自由カメラ

前後左右上下と向きを自分で動かせます。水槽の中にも入れます。潜ってみて、水中の減衰の式がもともと間違っていたことに気づきました。カメラが水槽の外にいる前提の近似で、水に入ると光路長を大きく見積もりすぎて画面が水色に潰れます。自由カメラのための追加ではなく、水中視点で誤っていた式の修正です。

底床の地形

盛り上がりがPlaneGeometry1枚、片面の板でした。側面がないので横から見ると透けるし、板と、その下にある一定厚の箱の高さが合っていなくて、前縁で箱の平らな上面が露出していました。高さ場から閉じた立体を作って置き換えています。

地形は基本傾斜に、うねり2オクターブ、手前中央の窪み(奥行きを錯覚させる前景の道)、石の背後の盛り土を重ねました。最初は制限を置かず、60cmで水深の56%まで土が来て「土に埋まった箱」になったので、40%までに抑えています。

石は青龍石に寄せて層理と割れを入れ、流木は7角柱をやめて14分割の筒に樹皮のうねりを乗せました。

そして生体が平らな面を前提のままでした。 60cmだと地形は1.3〜10.6cmの範囲で変わるのに、生体はずっと6.5cmの平面に貼り付いていて、エビが前縁で5cm近く浮いていました。高さ場を格子に焼いて、エビ・魚・餌の粒すべてを地形に追従させています。魚の脊椎点が地面に食い込んだ量は最大0cm、餌は18粒すべてが地面に着底しました。

今回の教訓

「食べない」「浮いている」という2件は、どちらも新しい機能の側の誤りではなく、既存のモデル(重心の定義、遊泳層の好み、底の高さ)との接続部で起きています。新しい層を足すとき、下の層が何を前提にしているかを確認していなかった。 窒素収支のような検証手段がない領域だと、こういう間違いは症状としてしか出てきません。

詳しくは実装ノートの§17 成長を収支から導く、§18 吸引摂餌と口の構造、§19 底床の地形、§20 地形との当たり判定に書きました。

できていないこと

病気(白点・ネオン病)は未実装。水草の水上葉・水中葉の転換もありません。魚の推力は運動学的で、尾の力から速度を出しているわけではありません。エビの背腹方向は剛体。濾材の目詰まり速度や吸着容量は推定値です。文献値と推定値の区別は、機材DBの全項目と実装ノートに残してあります。