Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions eech-campaign/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ A twelve-hour Georgia campaign recorded this way is in
Details, findings and limits: [`docs/engine.md`](docs/engine.md). The M1
candidate (Windows), what its evidence establishes and what it does not:
[`docs/m1-baseline.md`](docs/m1-baseline.md).
The M2 reference scenario (retail Lebanon, observed as Tacview and
structured observations, and checked): [`docs/m2-reference.md`](docs/m2-reference.md).

## The campaign kernel spike

Expand Down
23 changes: 21 additions & 2 deletions eech-campaign/crates/eech-world/lua/campaign.lua
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
-- record_every (frames), acmi (output path), seed, diagnostics_every and log_every (simulated
-- seconds), stop (hours: run all of them; conclusion: also stop when the war is decided or
-- stalls), stalemate_hours, defeated_hours, metrics (a JSON file of campaign metrics, metrics.lua),
-- checkpoint_every (simulated seconds between metrics snapshots), record (0: no recording)
-- checkpoint_every (simulated seconds between metrics snapshots), record (0: no recording),
-- observe (a JSON Lines file of structured observations from the same samples, observations.lua),
-- observe_every (simulated seconds between their full snapshots, default 60)

local args = host.args
local root = assert (args.root, "root=<installation root>")
Expand Down Expand Up @@ -77,6 +79,20 @@ if recording then host.open_recording {
longitude = tonumber (args.longitude) or scenario.longitude,
} end

local observe
if args.observe then
local a = scenario.affine
local sample_every_s = record_every * frame_ms / 1000
local snapshot_every = math.max (1, math.floor (tonumber (args.observe_every or "60") / sample_every_s + 0.5))
observe = dofile (here .. "observations.lua").open (args.observe, snapshot_every, {
scenario = args.scenario or "georgia", seed = tonumber (args.seed or "1"), frame_ms = frame_ms,
sample_every_s = sample_every_s, snapshot_every_samples = snapshot_every,
snapshots = "the first sample, every snapshot_every_samples samples, and each metrics checkpoint",
coordinates = "EECH world metres: x east, y up, z north; angles in radians",
affine = a and string.format ("%.10g %.10g %.10g %.10g %.10g %.10g %.10g %.10g", a.m[1], a.m[2], a.m[3], a.m[4], a.t[1], a.t[2], a.latitude, a.longitude) or "none",
})
end

-- campaign state summary: live units per side and kind, and keysites held
local function summary (objects)
local count = {}
Expand Down Expand Up @@ -122,9 +138,11 @@ for frame = 1, frames do
if frame % record_every == 0 then
local clock = engine:clock ()
local objects = engine:objects ()
local checkpoint = metrics.samples == 0 or frame % math.floor (checkpoint_every * 1000 / frame_ms) < record_every
if recording then host.record (clock.elapsed_seconds, objects) end
if observe then observe:sample (clock.elapsed_seconds, objects, checkpoint) end
metrics:sample (clock.elapsed_seconds, objects)
if metrics.samples == 1 or frame % math.floor (checkpoint_every * 1000 / frame_ms) < record_every then metrics:checkpoint (clock.elapsed_seconds, objects) end
if checkpoint then metrics:checkpoint (clock.elapsed_seconds, objects) end
-- keysites changing hands
for _, o in ipairs (objects) do
if o.kind == "keysite" then
Expand Down Expand Up @@ -186,4 +204,5 @@ if args.metrics then
frame_ms = frame_ms, sample_every = record_every, conclusion = conclusion or "none" })
host.log ("metrics written to " .. args.metrics)
end
if observe then observe:close () end
host.log ("campaign run complete")
108 changes: 108 additions & 0 deletions eech-campaign/crates/eech-world/lua/observations.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
-- Structured observations of the world, from the same samples the Tacview
-- recorder receives (campaign.lua observe=<file>). JSON Lines, one record per
-- line, in time order:
--
-- header the run's scenario, seed, sampling and map projection
-- appear an entity index reported for the first time, or again after it
-- was gone: every field below
-- retype an index reported with a different kind or sub-type than at the
-- last sample (EECH reused it): every field
-- alive the alive flag changed (a death, or an index reused by a live
-- entity of the same type): id, alive, position
-- side the side changed (a keysite captured): id, side, from
-- usable a keysite's usable state changed: id, usable, from
-- gone an index reported at the last sample and not at this one:
-- id and its last kind, sub-type, side and position
-- snap every object's full state: at the first sample, every `every`
-- samples, and whenever the caller asks (campaign.lua: at each
-- metrics checkpoint)
--
-- Every sample is compared with the one before, so an event's time is the
-- first sample that shows the change. `t` is EECH's session elapsed time (a
-- 32-bit float the engine accumulates frame by frame), printed with two
-- decimals as the Tacview frames are. Positions are EECH world metres (x east,
-- y up, z north, map coordinates); heading, pitch and roll are radians, as
-- engine:objects () reports them. Raw facts only: how the Tacview recorder
-- turns them into objects is checked against them (tools/observation-check.py).
local observations = {}
observations.__index = observations

local function str (s)
return '"' .. s:gsub ('[%c"\\]', function (ch) return string.format ("\\u%04x", ch:byte ()) end) .. '"'
end

local function full (o)
local parts = {
string.format ('"id":%d,"kind":%s,"sub":%d,"side":%s,"alive":%s,"type":%s', o.id, str (o.kind), o.sub_type, str (o.side),
o.alive and "true" or "false", str (o.type_name)),
}
if o.name then parts[#parts + 1] = '"name":' .. str (o.name) end
if o.group_id then parts[#parts + 1] = string.format ('"group":%d', o.group_id) end
parts[#parts + 1] = string.format ('"x":%.2f,"y":%.2f,"z":%.2f', o.position[1], o.position[2], o.position[3])
if o.kind == "keysite" then
parts[#parts + 1] = string.format ('"ammo":%.3f,"fuel":%.3f,"eff":%.3f,"usable":%s', o.ammo, o.fuel, o.efficiency, str (o.usable or "unknown"))
else
parts[#parts + 1] = string.format ('"h":%.5f,"p":%.5f,"r":%.5f', o.heading, o.pitch, o.roll)
end
return table.concat (parts, ",")
end

-- path: the JSON Lines file; every: samples between full snapshots; header: a
-- table of strings and numbers describing the run
function observations.open (path, every, header)
local f = assert (io.open (path, "wb"))
local keys, parts = {}, {}
for k in pairs (header) do keys[#keys + 1] = k end
table.sort (keys)
for _, k in ipairs (keys) do
local v = header[k]
parts[#parts + 1] = str (k) .. ":" .. (type (v) == "number" and string.format ("%.10g", v) or str (tostring (v)))
end
f:write ('{"ev":"header",', table.concat (parts, ","), "}\n")
return setmetatable ({ f = f, every = every, samples = 0, last = {} }, observations)
end

-- snapshot: also write a full snapshot at this sample
function observations:sample (seconds, objects, snapshot)
self.samples = self.samples + 1
local f, t, now = self.f, string.format ("%.2f", seconds), {}
for _, o in ipairs (objects) do
local was = self.last[o.id]
if not was then
f:write ('{"t":', t, ',"ev":"appear",', full (o), "}\n")
elseif was.kind ~= o.kind or was.sub_type ~= o.sub_type then
f:write ('{"t":', t, ',"ev":"retype",', full (o), "}\n")
else
if was.alive ~= o.alive then
f:write (string.format ('{"t":%s,"ev":"alive","id":%d,"alive":%s,"x":%.2f,"y":%.2f,"z":%.2f}\n', t, o.id,
o.alive and "true" or "false", o.position[1], o.position[2], o.position[3]))
end
if was.side ~= o.side then
f:write (string.format ('{"t":%s,"ev":"side","id":%d,"side":%s,"from":%s}\n', t, o.id, str (o.side), str (was.side)))
end
if o.kind == "keysite" and was.usable ~= o.usable then
f:write (string.format ('{"t":%s,"ev":"usable","id":%d,"usable":%s,"from":%s}\n', t, o.id, str (o.usable or "unknown"),
str (was.usable or "unknown")))
end
end
now[o.id] = { kind = o.kind, sub_type = o.sub_type, side = o.side, alive = o.alive, usable = o.usable, position = o.position }
end
local gone = {}
for id in pairs (self.last) do if not now[id] then gone[#gone + 1] = id end end
table.sort (gone)
for _, id in ipairs (gone) do
local was = self.last[id]
f:write (string.format ('{"t":%s,"ev":"gone","id":%d,"kind":%s,"sub":%d,"side":%s,"x":%.2f,"y":%.2f,"z":%.2f}\n', t, id, str (was.kind),
was.sub_type, str (was.side), was.position[1], was.position[2], was.position[3]))
end
self.last = now
if snapshot or self.samples == 1 or self.samples % self.every == 0 then
for _, o in ipairs (objects) do f:write ('{"t":', t, ',"ev":"snap",', full (o), "}\n") end
end
end

function observations:close ()
self.f:close ()
end

return observations
Loading
Loading