second
This commit is contained in:
parent
5f7fcb96ed
commit
01204dfa2f
34 changed files with 1498 additions and 0 deletions
107
test/ecology.test.mjs
Normal file
107
test/ecology.test.mjs
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import {performance} from 'node:perf_hooks';
|
||||
import {World,DT,H,GW,GH,CELL,INITIAL_SPECIES} from '../engine.js';
|
||||
import {inWater} from '../water.js';
|
||||
import {phytoplanktonMuMax,producerGrowthPerDay,updateZooplanktonCohort} from '../ecology/resources.js';
|
||||
import {interactionEdge,rallFeedingParameters,functionalResponseRatePerDay} from '../ecology/feeding.js';
|
||||
import {rateToProbability} from '../ecology/units.js';
|
||||
import {reproductionConfig,energyLimitedGrowth,initialStructuralMassKg} from '../ecology/demography.js';
|
||||
import {maximumSpeedMPerDay,routineTravelSpeedMPerDay,homeRangeRadiusM,sampleDispersalDistanceM,dailyMovementBudgetM,locomotionCalibration} from '../ecology/movement.js';
|
||||
import {FRESHWATER,TERRESTRIAL} from '../ecology/profiles.js';
|
||||
import {ClimateProvider} from '../ecology/climate.js';
|
||||
|
||||
const approx=(actual,expected,tol,msg)=>assert.ok(Math.abs(actual-expected)<=tol,`${msg}: ${actual} vs ${expected}`);
|
||||
const sumField=(field)=>field.reduce((sum,v)=>sum+v,0)*CELL*CELL;
|
||||
|
||||
// Published phytoplankton upper envelope and aquatic limitation structure.
|
||||
approx(phytoplanktonMuMax(10),1.52,.015,'muMax 10C');
|
||||
approx(phytoplanktonMuMax(20),2.86,.02,'muMax 20C');
|
||||
approx(phytoplanktonMuMax(30),5.38,.03,'muMax 30C');
|
||||
approx(producerGrowthPerDay(FRESHWATER,20,{light:.5,nutrientN:.8,nutrientP:.4,dissolvedOxygen:0}),phytoplanktonMuMax(20)*.4*.5,1e-12,'aquatic producer limitation');
|
||||
approx(producerGrowthPerDay(TERRESTRIAL,20,{moisture:1,light:.4}),.03*.4,1e-12,'terrestrial light limitation is applied once');
|
||||
|
||||
// Static food-web edges: no runtime interaction API, but the ecological graph remains explicit.
|
||||
assert.equal(interactionEdge('primary-consumer','producer').assimilationEfficiency,.45);
|
||||
assert.equal(interactionEdge('secondary-consumer','primary-consumer').assimilationEfficiency,.85);
|
||||
assert.equal(interactionEdge('primary-consumer','secondary-consumer'),null);
|
||||
|
||||
// Continuous rates must compose independently of timestep.
|
||||
const daily=rateToProbability(.7,1),hourly=rateToProbability(.7,DT),combined=1-Math.pow(1-hourly,24);
|
||||
approx(combined,daily,1e-12,'hourly rate composition');
|
||||
|
||||
// Handling time is truly connected to Holling denominator; Rall allometric/temperature scaling is live.
|
||||
{
|
||||
const edge=interactionEdge('secondary-consumer','primary-consumer');
|
||||
const p=rallFeedingParameters(edge,5,.1,20,FRESHWATER),pHot=rallFeedingParameters(edge,5,.1,30,FRESHWATER),term={...p,resourceDensity:2,q:1};
|
||||
const rate=functionalResponseRatePerDay(p,2,[term],1),slower=functionalResponseRatePerDay({...p,handlingTimeDays:p.handlingTimeDays*3},2,[{...term,handlingTimeDays:p.handlingTimeDays*3}],1);
|
||||
const competitor={...p,resourceDensity:4,q:1},withCompetition=functionalResponseRatePerDay(p,2,[term,competitor],1);
|
||||
assert.ok(rate>slower,'handling time is not connected to Holling denominator');
|
||||
assert.ok(withCompetition<rate,'multi-resource Holling denominator is not connected');
|
||||
assert.ok(pHot.attackRate>p.attackRate,'Rall temperature scaling does not increase attack rate');
|
||||
assert.ok(pHot.handlingTimeDays<p.handlingTimeDays,'Rall temperature scaling does not reduce handling time from 20C to 30C');
|
||||
assert.notEqual(rallFeedingParameters(edge,5,.1,20,FRESHWATER).attackRate,rallFeedingParameters(edge,5,1,20,FRESHWATER).attackRate,'resource mass does not affect attack rate');
|
||||
const scaled=rallFeedingParameters(edge,10,.2,20,FRESHWATER);
|
||||
approx(scaled.attackRate/p.attackRate,Math.pow(2,.62),1e-10,'Rall joint mass scaling attack');
|
||||
approx(scaled.handlingTimeDays/p.handlingTimeDays,Math.pow(2,-.14),1e-10,'Rall joint mass scaling handling');
|
||||
}
|
||||
|
||||
// The World predation path uses the multi-resource Holling denominator, not just the standalone helper.
|
||||
{const w=new World(913);for(const a of w.animals)a.dead=true;const predator=w.make(w.species.find(s=>s?.trophicRole==='secondary-consumer').g,w.species.find(s=>s?.trophicRole==='secondary-consumer').id,1000,1000,200),p1=w.make(w.species.find(s=>s?.trophicRole==='primary-consumer').g,w.species.find(s=>s?.trophicRole==='primary-consumer').id,1010,1000,100),p2=w.make(w.species.find(s=>s?.trophicRole==='primary-consumer').g,w.species.find(s=>s?.trophicRole==='primary-consumer').id,1020,1000,100);for(const a of [predator,p1,p2]){a.habitat='陸棲';a.dead=false}predator.structuralMassKg=3.5;p1.structuralMassKg=.1;p2.structuralMassKg=.1;w.animals=[predator,p1];w.hash.rebuild(w.animals);const one=w.predationRateForTarget(predator,p1,20).rate;w.animals.push(p2);w.hash.rebuild(w.animals);const two=w.predationRateForTarget(predator,p1,20).rate;assert.ok(one>0&&two>0&&two<one,'World predation path does not apply shared local prey saturation');}
|
||||
|
||||
// Reconstruct v38 procedural water without the sqrt(2) radius scale, then check only rough doubling.
|
||||
const W=4800;
|
||||
const clamp=(v,a,b)=>Math.max(a,Math.min(b,v));
|
||||
function baseWater(seed){let value=(seed^0x397b4c63)>>>0;const random=()=>{let t=value=(value+0x6D2B79F5)>>>0;t=Math.imul(t^t>>>15,t|1);t^=t+Math.imul(t^t>>>7,t|61);return((t^t>>>14)>>>0)/4294967296};const edge=Math.floor(random()*4),radius=320+random()*110,along=.14+random()*.72;const start=edge===0?{x:radius*.42,y:H*along}:edge===1?{x:W-radius*.42,y:H*along}:edge===2?{x:W*along,y:radius*.42}:{x:W*along,y:H-radius*.42};const destination=edge===0?{x:W-300,y:H*(.14+random()*.72)}:edge===1?{x:300,y:H*(.14+random()*.72)}:edge===2?{x:W*(.14+random()*.72),y:H-300}:{x:W*(.14+random()*.72),y:300};const water=[{...start,r:radius,seed:random()*Math.PI*2}];for(let i=1;i<7;i++){const last=water.at(-1),r=290+random()*170,heading=Math.atan2(destination.y-last.y,destination.x-last.x)+(random()-.5)*1.25,step=(last.r+r)*(.43+random()*.12);water.push({x:clamp(last.x+Math.cos(heading)*step,r*.42,W-r*.42),y:clamp(last.y+Math.sin(heading)*step,r*.42,H-r*.42),r,seed:random()*Math.PI*2})}for(const index of [2,4]){const parent=water[index],before=water[index-1],r=270+random()*140,heading=Math.atan2(parent.y-before.y,parent.x-before.x)+(random()<.5?-1:1)*(1.05+random()*.65),step=(parent.r+r)*(.42+random()*.13);water.push({x:clamp(parent.x+Math.cos(heading)*step,r*.42,W-r*.42),y:clamp(parent.y+Math.sin(heading)*step,r*.42,H-r*.42),r,seed:random()*Math.PI*2})}return water}
|
||||
function wetCells(water){let n=0;for(let j=0;j<GH;j++)for(let i=0;i<GW;i++)if(inWater(water,(i+.5)*CELL,(j+.5)*CELL))n++;return n}
|
||||
const ratios=[];
|
||||
for(const seed of [481516,1,123456789,0xffffffff]){const base=wetCells(baseWater(seed)),world=new World(seed),now=world.wet.reduce((a,b)=>a+b,0),ratio=now/base;ratios.push(ratio);assert.ok(ratio>1.7&&ratio<2.3,`rough water area ratio ${ratio}`)}
|
||||
{const w=new World(77),edited=w.water.map((v,i)=>({...v,r:i===0?v.r*.98:v.r}));assert.equal(w.setWater(edited),true);assert.deepEqual(w.water,edited,'user water geometry was altered')}
|
||||
|
||||
// Hirt maximum-speed coefficients and separate routine/home-range/dispersal paths.
|
||||
assert.ok(maximumSpeedMPerDay(3,'running')>routineTravelSpeedMPerDay(3,900,'running'));
|
||||
assert.ok(maximumSpeedMPerDay(3,'flying')>maximumSpeedMPerDay(3,'running'));
|
||||
assert.deepEqual((({a,b,h,i})=>({a,b,h,i}))(locomotionCalibration('flying')),{a:142.8,b:.24,h:2.4,i:-.72});
|
||||
assert.deepEqual((({a,b,h,i})=>({a,b,h,i}))(locomotionCalibration('running')),{a:25.5,b:.26,h:22,i:-.60});
|
||||
assert.deepEqual((({a,b,h,i})=>({a,b,h,i}))(locomotionCalibration('swimming')),{a:11.2,b:.36,h:19.5,i:-.56});
|
||||
assert.ok(homeRangeRadiusM(10,'swimming')>homeRangeRadiusM(10,'running'));
|
||||
assert.ok(sampleDispersalDistanceM(()=>.5,3,'running')>0);
|
||||
|
||||
// Cohort resource path is active.
|
||||
{const z=updateZooplanktonCohort(.1,.02,1);assert.ok(z.producerConsumedKgPerM2>0);assert.notEqual(z.nextZooplanktonKgPerM2,.02)}
|
||||
|
||||
// Reproduction must not kill the parent.
|
||||
{const w=new World(481516),parent=w.animals.find(a=>w.roleOf(a)==='primary-consumer');w.time=100;parent.age=parent.g.maturityAge+1;parent.energy=w.maxEnergy(parent);parent.lastBirth=w.time-reproductionConfig('primary-consumer').reproductionIntervalDays-1;const before=w.animals.length;w.reproduce(parent);assert.equal(parent.dead,false);assert.ok(w.animals.length>before)}
|
||||
|
||||
// Juvenile structural growth is energy-limited rather than age-only.
|
||||
{const birth=initialStructuralMassKg(10,.2,0,100);approx(birth,2,1e-12,'birth structural mass');const rich=energyLimitedGrowth({structuralMassKg:birth,adultMassKg:10,energyKJ:5000,ageDays:10,maturityAgeDays:100,dtDays:1}),poor=energyLimitedGrowth({structuralMassKg:birth,adultMassKg:10,energyKJ:500,ageDays:10,maturityAgeDays:100,dtDays:1});assert.ok(rich.gainKg>0,'surplus energy did not produce structural growth');assert.ok(rich.energyKJ<5000,'growth did not consume energy');assert.equal(poor.gainKg,0,'growth occurred below reserve floor')}
|
||||
|
||||
// Profile switching remains an internal UI operation. Climate series support is internal, not a World/Worker experiment API.
|
||||
{const w=new World(7);assert.throws(()=>w.setAquaticProfile('bogus'));const c=new ClimateProvider();c.setSeries('surfaceTemperatureC',[12,13,14]);c.setSeries('soilMoisture',[.2,.4,.6]);c.setSeries('nutrientN',[.3,.4,.5]);assert.equal(c.temperatureC(1,{aquatic:true}),13);assert.equal(c.soilMoisture(1,.9),.4);assert.equal(c.value('nutrientN',1),.4)}
|
||||
|
||||
// Ordinary stepping: finite state, no spontaneous immigration/speciation, daily movement budget enforced.
|
||||
const bench=new World(481516),startSpeciesId=bench.nextSpeciesId,start=performance.now();
|
||||
for(let i=0;i<30*24;i++)bench.step();
|
||||
const elapsedMs=performance.now()-start,stats=bench.stats(),zooMass=sumField(bench.zooplankton);
|
||||
assert.equal(startSpeciesId,INITIAL_SPECIES);assert.equal(bench.nextSpeciesId,INITIAL_SPECIES);
|
||||
assert.ok(Number.isFinite(stats.plant)&&stats.plant>=0);assert.ok(Number.isFinite(zooMass)&&zooMass>=0);
|
||||
assert.ok(bench.plants.every(Number.isFinite));assert.ok(bench.zooplankton.every(Number.isFinite));
|
||||
assert.ok(bench.animals.every(a=>[a.x,a.y,a.energy,a.age,a.structuralMassKg,a.movedToday].every(Number.isFinite)));
|
||||
for(const a of bench.animals){if(a.dead)continue;const mode=bench.species[a.sid]?.locomotionMode||'running',budget=dailyMovementBudgetM(bench.biomass(a),a.g.moveSpeed,mode);assert.ok(a.movedToday<=budget+1e-6,`daily movement budget exceeded: ${a.movedToday} > ${budget}`)}
|
||||
assert.equal(bench.resourceFields[0].representation,'field');assert.equal(bench.resourceFields[1].representation,'cohort');
|
||||
assert.deepEqual(Object.keys(stats).sort(),['generation','plant','population','species','temp','time'].sort(),'detailed diagnostic output leaked into UI stats');
|
||||
|
||||
// Requested omissions and removed experiment API/output stay absent.
|
||||
const codeFiles=['../engine.js','../worker.js','../ecology/profiles.js','../ecology/resources.js','../ecology/metabolism.js','../ecology/feeding.js','../ecology/demography.js','../ecology/movement.js','../ecology/schema.js'];
|
||||
const code=(await Promise.all(codeFiles.map(f=>readFile(new URL(f,import.meta.url),'utf8')))).join('\n');
|
||||
assert.ok(!/DisturbanceEvent|schedule-disturbance|pesticide|harvest|habitat-loss|nutrient-pulse/.test(code),'human disturbance code remains');
|
||||
assert.ok(!/waterDepth|depthLayer|mixed-layer|deepLayer/.test(code),'water-depth code remains');
|
||||
assert.ok(!/doubleWaterArea|target=Math\.min\(GW\*GH,base\*2\)|for\(let iter=0;iter<14/.test(code),'exact water-area optimizer remains');
|
||||
assert.ok(!/nearest=1400;for\(const b of this\.animals\)/.test(code),'omniscient global prey scan remains');
|
||||
assert.ok(!/set-interactions|set-climate|run-batch|batch-result|batch-progress|validateBatchRequest|setInteractions|setClimate|introduce\(/.test(code),'removed experiment API remains');
|
||||
assert.ok(!/consumptionFlux|assimilationFlux|respirationLoss|resourceTurnover|occupiedArea|meanBodyMass|biomassByGroup|populationByGroup|trophicFlux/.test(code),'removed detailed output remains');
|
||||
|
||||
// Deterministic repeatability after removing diagnostics.
|
||||
const a=new World(20260929),b=new World(20260929);for(let i=0;i<7*24;i++){a.step();b.step()}assert.deepEqual(a.stats(),b.stats());assert.equal(sumField(a.zooplankton),sumField(b.zooplankton));
|
||||
|
||||
console.log(JSON.stringify({ok:true,dtDays:DT,waterAreaRatios:ratios,benchmark30DaysMs:+elapsedMs.toFixed(1),benchmark30DaysPopulation:stats.population,benchmark30DaysSpecies:stats.species.length,producerBiomassKg:+stats.plant.toFixed(2),zooplanktonBiomassKg:+zooMass.toFixed(2)},null,2));
|
||||
Loading…
Add table
Add a link
Reference in a new issue