← Natura·Docs/Modules/Source

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 };
});