sensing.js
131 lines · in natura-src-1.0.0.zip as natura/sensing.js · a module · its entry in the module reference · its test: sensing-test.js
// sensing.js -- Natura SENSING (ONTOLOGY-FOLD-IN #3, Life > sensing): what a creature can actually DETECT, and
// its LIMITS. mind.js holds a creature's BELIEF (its model of the world); this is the channel BENEATH belief --
// the raw cue reaching a sensor, through a real MEDIUM, with a real range. A prey gives off cues; each travels
// differently: SCENT drifts DOWNWIND and lingers, so a nose downwind smells prey a nose upwind never will;
// SOUND radiates far and around things; SIGHT is bright by day and near-blind in the dark or fog; HEAT betrays a
// warm body but only close. So detection is an INFORMATION GAME, not a fact: the same prey is loud to one sense
// and silent to another, findable from one quarter and hidden from the next. Couples to WEATHER (wind carries
// scent, fog/night blind sight) and turns PREDATION's "the predator sees all" into "senses within its means" --
// the hunt begins only where a cue crosses the sensor's threshold. Field-based, deterministic, node-testable. UMD.
(function (root, factory) {
if (typeof module !== 'undefined' && module.exports) module.exports = factory();
else root.Sensing = factory();
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
// config: { N, sources ([{x,y,scent,sound,heat,vis}] | fn(clock)->array), wind ({x,y} | via weather), light
// (0..1 | via celestial), fog (0..1 | via weather), scentEmit, scentDecay, scentDiffuse, windSpeed, soundK,
// sightK, heatK, heatRange, weather, celestial, maxDtDays }
function makeSensing(config) {
config = config || {};
const N = config.N || 64;
const scentEmit = config.scentEmit != null ? config.scentEmit : 0.9; // cue laid down at the source per day
const scentDecay = config.scentDecay != null ? config.scentDecay : 0.86; // scent fades (per day-step) -- a trail goes cold
const scentDiffuse = config.scentDiffuse != null ? config.scentDiffuse : 0.14;
const windSpeed = config.windSpeed != null ? config.windSpeed : 1.5; // cells/day the plume is carried downwind (kept sub-cell so the plume stays continuous, not striped)
const soundK = config.soundK != null ? config.soundK : 6; // sound attenuation (small = carries far)
const sightK = config.sightK != null ? config.sightK : 22; // sight attenuation (large = short range)
const heatK = config.heatK != null ? config.heatK : 60; // heat attenuation (very short)
const heatRange = config.heatRange != null ? config.heatRange : 0.28; // heat is useless past this (world units)
const maxDd = config.maxDtDays != null ? config.maxDtDays : 2;
const weatherName = config.weather || null;
const celestialName = config.celestial || null;
const windCfg = config.wind || { x: 1, y: 0 };
const lightCfg = config.light != null ? config.light : 1;
const fogCfg = config.fog != null ? config.fog : 0;
const srcCfg = config.sources || [{ x: 0, y: 0, scent: 1, sound: 1, heat: 1, vis: 1 }];
const gx = (i) => (i / (N - 1)) * 2 - 1;
const xtoi = (x) => clamp((x + 1) / 2 * (N - 1), 0, N - 1);
const scal = (c) => typeof c === 'function' ? c() : c;
// bilinear sample of a field at continuous (fi,fj)
function sample(fld, fi, fj) {
const i0 = Math.floor(fi), j0 = Math.floor(fj), fx = fi - i0, fy = fj - j0;
const i1 = clamp(i0 + 1, 0, N - 1), j1 = clamp(j0 + 1, 0, N - 1), ci = clamp(i0, 0, N - 1), cj = clamp(j0, 0, N - 1);
const a = fld[cj * N + ci], b = fld[cj * N + i1], c = fld[j1 * N + ci], d = fld[j1 * N + i1];
return a * (1 - fx) * (1 - fy) + b * fx * (1 - fy) + c * (1 - fx) * fy + d * fx * fy;
}
return {
name: config.name || 'sensing',
domain: 'life',
scale: 'ecological',
params: config,
init() {
return {
N, scent: new Float32Array(N * N), sound: new Float32Array(N * N),
sight: new Float32Array(N * N), heat: new Float32Array(N * N), detect: new Float32Array(N * N),
sources: [], light: 1, fog: 0, wind: { x: windCfg.x, y: windCfg.y }
};
},
step(st, ctx, dt) {
const dd = clamp(dt / 86400, 0, maxDd); if (dd <= 0) return st;
// --- the MEDIUM: wind (carries scent), light + fog (govern sight) ---
let wind = { x: windCfg.x, y: windCfg.y };
const wf = weatherName && ctx && ctx.fields[weatherName] ? ctx.fields[weatherName] : null;
if (wf && wf.windX && wf.windY) { let sx = 0, sy = 0; const M = N * N; for (let k = 0; k < M; k++) { sx += wf.windX[k]; sy += wf.windY[k]; } wind = { x: sx / M, y: sy / M }; }
const cf = celestialName && ctx && ctx.fields[celestialName] ? ctx.fields[celestialName] : null;
let light = cf && cf.daylight != null ? cf.daylight : scal(lightCfg);
let fog = scal(fogCfg);
if (wf && wf.humidity) { let sh = 0; const M = N * N; for (let k = 0; k < M; k++) sh += wf.humidity[k]; fog = clamp(fog + Math.max(0, sh / M - 0.7) * 2, 0, 1); } // very humid air fogs
light = clamp(light, 0, 1); fog = clamp(fog, 0, 1);
const clarity = light * (1 - fog);
st.light = light; st.fog = fog; st.wind = wind;
// --- the SOURCES (prey emitting cues) ---
const sources = typeof srcCfg === 'function' ? srcCfg(ctx && ctx.clock) : srcCfg;
st.sources = sources;
// --- SCENT: emit at each source, ADVECT downwind (semi-Lagrangian), diffuse a little, decay ---
const scent = st.scent, tmp = st._t || (st._t = new Float32Array(N * N));
const wlen = Math.hypot(wind.x, wind.y) || 1e-6, ux = wind.x / wlen, uy = wind.y / wlen;
const carry = windSpeed * wlen * dd; // how far downwind the plume moves this step
for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) {
const k = j * N + i;
tmp[k] = sample(scent, i - ux * carry, j - uy * carry) * Math.pow(scentDecay, dd); // pull from upwind; fade
}
if (scentDiffuse > 0) { // a little lateral spread
const D = Math.min(0.2, scentDiffuse * dd);
for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) { const k = j * N + i;
let acc = 0, n = 0;
if (i > 0) { acc += tmp[k - 1]; n++; } if (i < N - 1) { acc += tmp[k + 1]; n++; }
if (j > 0) { acc += tmp[k - N]; n++; } if (j < N - 1) { acc += tmp[k + N]; n++; }
scent[k] = tmp[k] + D * (acc / Math.max(n, 1) - tmp[k]);
}
} else { scent.set(tmp); }
for (const s of sources) { const si = Math.round(xtoi(s.x)), sj = Math.round(xtoi(s.y)); scent[sj * N + si] += (s.scent != null ? s.scent : 1) * scentEmit * dd; }
// --- SOUND / SIGHT / HEAT: near-instantaneous, recomputed from the sources + medium each step ---
const sound = st.sound, sight = st.sight, heat = st.heat, detect = st.detect;
for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) {
const k = j * N + i, x = gx(i), y = gx(j);
let snd = 0, sgt = 0, ht = 0;
for (const s of sources) {
const dx = x - s.x, dy = y - s.y, d2 = dx * dx + dy * dy, d = Math.sqrt(d2);
snd = Math.max(snd, (s.sound != null ? s.sound : 1) / (1 + soundK * d2)); // radiates far
sgt = Math.max(sgt, clarity * (s.vis != null ? s.vis : 1) / (1 + sightK * d2)); // bright by day, blind in dark/fog
if (d < heatRange) ht = Math.max(ht, (s.heat != null ? s.heat : 1) / (1 + heatK * d2)); // warm body, only close
}
sound[k] = snd; sight[k] = sgt; heat[k] = ht;
const sc = clamp(scent[k], 0, 1);
detect[k] = Math.max(sc, snd, sgt, ht); // the BEST channel available at this cell
}
return st;
},
// OUT: one field per channel + the combined best-channel detectability, and the medium readouts.
// A predator at (i,j) can begin its hunt where detect[k] crosses its threshold -- "senses within its means".
fields(st) {
return { N: st.N, scent: st.scent, sound: st.sound, sight: st.sight, heat: st.heat, detect: st.detect,
light: st.light, fog: st.fog, wind: st.wind, sources: st.sources };
},
events(st) { return []; }
};
}
return { makeSensing };
});