-- ====================================================================
-- numodel-coach.lua --- write numodel models as CMA Coach 7 files
-- numodel-coach.lua v0.12.0 2026/10/28
-- ====================================================================
--
-- Copyright (C) 2026 Paul Zuurbier
--
-- This work may be distributed and/or modified under the conditions
-- of the LaTeX Project Public License, either version 1.3c of this
-- license or (at your option) any later version. The latest version
-- of this license is in https://www.latex-project.org/lppl.txt
--
-- This work has the LPPL maintenance status 'maintained'.
-- The Current Maintainer of this work is Paul Zuurbier.
--
-- This work consists of the files numodel-coach.dtx,
-- numodel-coach.ins, the derived file numodel-coach.sty, and the
-- companion Lua files numodel-coach.lua and numodel-coach-template.lua.
-- ====================================================================
-- Turns a numodel model into a Coach 7 modelling activity (.cma7) in
-- text mode. The model text comes from numodel.plaintext (Coachtaal),
-- the model data from numodel.get_model; this file only knows the
-- Coach file format.
--
-- The format is not documented by CMA. What is known (reverse
-- engineered from files saved by Coach 7.0):
-- * a 16-byte header starting with "CMA ", then records;
-- * container: u64 length, 0e 00 00 00 00, u8 name length, name,
-- children (length counts the whole record);
-- * leaf: u32 length, u32 0x0b, u8 name length, u8 flag, u8 type,
-- name, value. Types: 0 byte, 2 int32, 3 80-bit extended,
-- 4 ANSI (cp1252) text, 6 UTF-16LE text. Every text is stored
-- twice: as name (ANSI) and name_uuuu (UTF-16). Flag 1 marks an
-- unnamed long text, preceded by four zero bytes;
-- * no checksums.
-- Model-relevant records:
-- Description/CanSwitchModelModes 1 = pupil may switch between the
-- graphical and the text view. The text view is derived from
-- the graphical model (ModelXML), so switching loses a text-only
-- model: numodel-coach switches it off by default.
-- GrModMain/Mode 1 = text view
-- ModelXML graphical model; its ,
-- and set the iteration count:
-- (stop - start)/step + 1
-- ModelBody, ModelInit model rules, initial values
-- VarList variables shown in table/graphs
-- ====================================================================
local C = {}
numodelcoach = C
-- The template is found through kpse inside LuaTeX; standalone texlua
-- (the unit tests) sets C.template itself.
local ok, template_file = pcall(function()
return kpse.find_file("numodel-coach-template.lua", "tex")
end)
C.template = ok and template_file and dofile(template_file) or nil
-- --- encoding ---------------------------------------------------------
-- UTF-8 -> UTF-16LE.
local function utf16le(s)
local out = {}
for _, cp in utf8.codes(s) do
if cp >= 0x10000 then
cp = cp - 0x10000
local hi, lo = 0xD800 + (cp >> 10), 0xDC00 + (cp & 0x3FF)
out[#out + 1] = string.pack(" cp1252 (Windows-1252), '?' for what it cannot hold -- the
-- same as Coach writes in its ANSI copies (Δt -> ?t).
local cp1252_high = {
[0x20AC] = 0x80, [0x201A] = 0x82, [0x0192] = 0x83, [0x201E] = 0x84,
[0x2026] = 0x85, [0x2020] = 0x86, [0x2021] = 0x87, [0x02C6] = 0x88,
[0x2030] = 0x89, [0x0160] = 0x8A, [0x2039] = 0x8B, [0x0152] = 0x8C,
[0x017D] = 0x8E, [0x2018] = 0x91, [0x2019] = 0x92, [0x201C] = 0x93,
[0x201D] = 0x94, [0x2022] = 0x95, [0x2013] = 0x96, [0x2014] = 0x97,
[0x02DC] = 0x98, [0x2122] = 0x99, [0x0161] = 0x9A, [0x203A] = 0x9B,
[0x0153] = 0x9C, [0x017E] = 0x9E, [0x0178] = 0x9F,
}
local function cp1252(s)
local out = {}
for _, cp in utf8.codes(s) do
if cp < 0x80 or (cp >= 0xA0 and cp <= 0xFF) then
out[#out + 1] = string.char(cp)
else
out[#out + 1] = string.char(cp1252_high[cp] or 0x3F)
end
end
return table.concat(out)
end
-- Number -> 80-bit x87 extended, little endian (Delphi "Extended").
local function ext80(x)
if x == 0 then return string.rep("\0", 10) end
local sign = x < 0 and 0x8000 or 0
local m, e = math.frexp(math.abs(x)) -- x = m * 2^e, 0.5 <= m < 1
local hi = math.floor(m * 2^32) -- top 32 mantissa bits
local lo = math.floor((m * 2^32 - hi) * 2^32)
return string.pack(""] = ">" }
local function esc(s) return (s:gsub("[&<>]", html_escape)) end
local text_symbols = {
ldots = "…", dots = "…", textellipsis = "…", cdot = "·",
times = "×", degree = "°", celsius = "°C", degreeCelsius = "°C",
pm = "±", approx = "≈", leq = "≤", leqslant = "≤", geq = "≥",
geqslant = "≥", neq = "≠", ne = "≠", to = "→", rightarrow = "→",
infty = "∞", textendash = "–", textemdash = "—", euro = "€",
percent = "%", quad = " ", qquad = " ", space = " ",
}
-- Relations in math, set with spaces around them.
local math_relations = { leq = "≤", leqslant = "≤", geq = "≥",
geqslant = "≥", neq = "≠", ne = "≠", approx = "≈", to = "→",
rightarrow = "→", propto = "∝" }
-- LaTeX accents and special letters (\"u, \'e, \c{c}, \ss).
local accents = {
['"'] = { a = "ä", e = "ë", i = "ï", o = "ö", u = "ü", y = "ÿ",
A = "Ä", E = "Ë", I = "Ï", O = "Ö", U = "Ü" },
["'"] = { a = "á", e = "é", i = "í", o = "ó", u = "ú", y = "ý",
A = "Á", E = "É", I = "Í", O = "Ó", U = "Ú", c = "ć",
n = "ń", s = "ś", z = "ź" },
["`"] = { a = "à", e = "è", i = "ì", o = "ò", u = "ù",
A = "À", E = "È", I = "Ì", O = "Ò", U = "Ù" },
["^"] = { a = "â", e = "ê", i = "î", o = "ô", u = "û",
A = "Â", E = "Ê", I = "Î", O = "Ô", U = "Û" },
["~"] = { a = "ã", n = "ñ", o = "õ", A = "Ã", N = "Ñ", O = "Õ" },
c = { c = "ç", C = "Ç", s = "ş", S = "Ş" },
}
local letters = { ss = "ß", o = "ø", O = "Ø", ae = "æ", AE = "Æ",
oe = "œ", OE = "Œ", aa = "å", AA = "Å", l = "ł", L = "Ł", i = "ı",
j = "ȷ" }
local ignored = { noindent = true, medskip = true, smallskip = true,
bigskip = true, centering = true, relax = true, ignorespaces = true,
displaystyle = true, left = true, right = true }
local wrap_tags = { textbf = "b", bfseries = nil, emph = "i", textit = "i",
textsl = "i", underline = "u", textsuperscript = "sup",
textsubscript = "sub" }
local plain_groups = { text = true, textrm = true, textnormal = true,
mbox = true, textsf = true, texttt = true, mathrm = true,
mathit = true, mathbf = true, operatorname = true, textup = true }
-- Read one argument at s[i]: a braced group (returns its content) or a
-- single token. Returns content and the index after it.
local function read_arg(s, i)
i = s:find("%S", i) or #s + 1
local c = s:sub(i, i)
if c == "{" then
local depth, j = 0, i
while j <= #s do
local d = s:sub(j, j)
if d == "\\" then j = j + 1
elseif d == "{" then depth = depth + 1
elseif d == "}" then
depth = depth - 1
if depth == 0 then return s:sub(i + 1, j - 1), j + 1 end
end
j = j + 1
end
return s:sub(i + 1), #s + 1
elseif c == "\\" then
local cs = s:match("^\\%a+", i) or s:sub(i, i + 1)
return cs, i + #cs
end
return c, i + 1
end
-- Accent command at s[i] applied to its argument (\"u, \"{u}, \"{\i}).
local function apply_accent(acc, s, i, ctx)
local a; a, i = read_arg(s, i)
-- \"\i: the space after a control word belongs to it, as in TeX.
if a:match("^\\%a+$") and s:sub(i, i) == " " then i = i + 1 end
a = a:gsub("^\\([ij])%s*$", "%1")
local ch = accents[acc][a]
if ch then return ch, i end
ctx.warn("instruction: accent \\" .. acc .. " on '" .. a .. "' is not converted")
return esc(a), i
end
local function fmt_num(s, ctx)
s = s:gsub("%s", "")
return (s:gsub("(%d)%.(%d)", "%1" .. ctx.decimal .. "%2"))
end
-- Unit for display: m/s^2 -> m/s2.
local function unit_html(u)
local p = numodel.plain_unit(u)
return (esc(p):gsub("%^(%-?%d+)", "%1"))
end
local convert, convert_cmd
-- Math: letters italic, _ and ^ as sub/sup, \frac as a/b.
local function convert_math(s, ctx)
local out, i = {}, 1
local function emit(x) out[#out + 1] = x end
while i <= #s do
local c = s:sub(i, i)
if c:match("%a") then
local run = s:match("^%a+", i)
emit("" .. run .. ""); i = i + #run
elseif c == "_" or c == "^" then
local a; a, i = read_arg(s, i + 1)
local tag = c == "_" and "sub" or "sup"
emit("<" .. tag .. ">" .. convert_math(a, ctx) .. "" .. tag .. ">")
elseif c == "{" then
local a; a, i = read_arg(s, i)
emit(convert_math(a, ctx))
elseif c == "\\" then
local cs = s:match("^\\(%a+)", i)
if cs then
i = i + 1 + #cs
if plain_groups[cs] then
local a; a, i = read_arg(s, i)
emit(convert(a, ctx, false))
elseif cs == "frac" or cs == "tfrac" or cs == "dfrac" then
local a, b; a, i = read_arg(s, i); b, i = read_arg(s, i)
local A, B = convert_math(a, ctx), convert_math(b, ctx)
if a:find("[%+%-%s]") then A = "(" .. A .. ")" end
if b:find("[%+%-%s]") then B = "(" .. B .. ")" end
emit(A .. "/" .. B .. " ") -- 1/2 mv, not 1/2mv
elseif cs == "sqrt" then
local a; a, i = read_arg(s, i)
emit("√(" .. convert_math(a, ctx) .. ")")
elseif numodel.greek[cs] then emit(numodel.greek[cs])
elseif math_relations[cs] then
emit(" " .. math_relations[cs] .. " ")
elseif text_symbols[cs] then emit(text_symbols[cs])
elseif cs == "qty" or cs == "SI" or cs == "num"
or cs == "unit" or cs == "si" then
i = i - 1 - #cs
local a; a, i = convert_cmd(s, i, ctx)
emit(a)
elseif not ignored[cs] then
ctx.warn("instruction: unknown math command \\" .. cs)
end
else
local sym = s:sub(i + 1, i + 1)
i = i + 2
if sym == "," or sym == ";" or sym == ":" then emit(" ")
elseif sym ~= "!" then emit(esc(sym)) end
end
elseif c:match("%s") then
i = i + 1 -- TeX ignores spaces in math
elseif c == "=" or c == "<" or c == ">" then
emit(" " .. esc(c) .. " "); i = i + 1
elseif c == "+" or c == "-" then
-- Binary (spaced) after an operand, unary otherwise.
local sym = c == "-" and "−" or "+"
local prev = out[#out]
if prev and not prev:match("[%s(]$") then
emit(" " .. sym .. " ")
else
emit(sym)
end
i = i + 1
else
emit(esc(c)); i = i + 1
end
end
return (table.concat(out):gsub("", ""):gsub(" +", " "))
end
-- One control sequence at s[i] (the backslash). Returns HTML and the
-- index after the command and its arguments.
convert_cmd = function(s, i, ctx)
local cs = s:match("^\\(%a+)", i)
if not cs then
local sym = s:sub(i + 1, i + 1)
if sym == "\\" then return " ", i + 2 end
if sym == "," then return " ", i + 2 end
if sym == "-" or sym == "/" then return "", i + 2 end
if accents[sym] then return apply_accent(sym, s, i + 2, ctx) end
return esc(sym), i + 2
end
i = i + 1 + #cs
if s:sub(i, i) == " " then i = i + 1 end -- detokenize space
local a
if cs == "c" then return apply_accent("c", s, i, ctx)
elseif letters[cs] then
if s:sub(i, i + 1) == "{}" then i = i + 2 end -- \ss{}
return letters[cs], i
elseif wrap_tags[cs] then
a, i = read_arg(s, i)
local t = wrap_tags[cs]
return "<" .. t .. ">" .. convert(a, ctx, false) .. "" .. t .. ">", i
elseif plain_groups[cs] then
a, i = read_arg(s, i)
return convert(a, ctx, false), i
elseif cs == "qty" or cs == "SI" then
local b
a, i = read_arg(s, i); b, i = read_arg(s, i)
return fmt_num(a, ctx) .. " " .. unit_html(b), i
elseif cs == "num" then
a, i = read_arg(s, i)
return fmt_num(a, ctx), i
elseif cs == "unit" or cs == "si" then
a, i = read_arg(s, i)
return unit_html(a), i
elseif cs == "par" then return "\0P", i
elseif cs == "newline" or cs == "linebreak" then return " ", i
elseif cs == "item" then
if s:match("^%s*%[", i) then
local j = s:find("]", i, true) or i
i = j + 1
end
return "\0I", i
elseif cs == "begin" or cs == "end" then
a, i = read_arg(s, i)
local tag = (a == "itemize" and "ul") or (a == "enumerate" and "ol")
if not tag then
ctx.warn("instruction: environment " .. a .. " is not converted")
return "", i
end
if cs == "begin" then return "\0P<" .. tag .. ">\0L", i end
return "" .. tag .. ">\0P", i
elseif numodel.greek[cs] then return numodel.greek[cs], i
elseif text_symbols[cs] then return text_symbols[cs], i
elseif ignored[cs] then return "", i
end
ctx.warn("instruction: unknown command \\" .. cs .. "; its text is kept")
if s:match("^%s*{", i) then
a, i = read_arg(s, i)
return convert(a, ctx, false), i
end
return "", i
end
-- Text mode.
convert = function(s, ctx)
local out, i = {}, 1
local function emit(x) out[#out + 1] = x end
while i <= #s do
local c = s:sub(i, i)
if c == "\\" then
if s:sub(i, i + 1) == "\\(" then
local j = s:find("\\)", i + 2, true) or #s + 1
emit(convert_math(s:sub(i + 2, j - 1), ctx)); i = j + 2
else
local h; h, i = convert_cmd(s, i, ctx); emit(h)
end
elseif c == "$" then
local j = s:find("$", i + 1, true) or #s + 1
emit(convert_math(s:sub(i + 1, j - 1), ctx)); i = j + 1
elseif c == "{" then
local a; a, i = read_arg(s, i); emit(convert(a, ctx, false))
elseif c == "}" then i = i + 1
elseif c == "~" then emit(" "); i = i + 1
elseif s:sub(i, i + 2) == "---" then emit("—"); i = i + 3
elseif s:sub(i, i + 1) == "--" then emit("–"); i = i + 2
elseif s:sub(i, i + 1) == "``" then emit("“"); i = i + 2
elseif s:sub(i, i + 1) == "''" then emit("”"); i = i + 2
elseif c == "%" then
i = (s:find("\n", i, true) or #s) + 1 -- comment
else
emit(esc(c)); i = i + 1
end
end
return table.concat(out)
end
-- LaTeX source (detokenized) -> { html = ..., text = ... }. decimal is
-- the decimal mark for \num/\qty (default ",").
function C.instruction_html(src, decimal, warn)
local ctx = { decimal = decimal or ",", warn = warn or function() end }
local raw = convert(src, ctx)
-- \item markers: first one after
/ opens the item.
raw = raw:gsub("\0L%s*\0I", "
"):gsub("\0I", "
")
:gsub("\0L", "
")
local blocks = {}
for chunk in (raw .. "\0P"):gmatch("(.-)\0P") do
chunk = chunk:gsub("%s+", " "):gsub("^ ", ""):gsub(" $", "")
chunk = chunk:gsub("
", "
"):gsub("
", "")
if chunk ~= "" then
if chunk:match("^<[uo]l>") then blocks[#blocks + 1] = chunk
else blocks[#blocks + 1] = "
" .. chunk .. "
" end
end
end
local html = "" .. table.concat(blocks) .. ""
local text = html:gsub("
", "- "):gsub("
", "\n")
:gsub("", "\n"):gsub(" ", "\n"):gsub("<[^>]+>", "")
:gsub("<", "<"):gsub(">", ">"):gsub("&", "&")
return { html = html, text = (text:gsub("\n+$", "")) }
end
-- Instructions stored per model prefix by the coachinstruction
-- environment, used by the next \coachmodel for that prefix.
C.instructions = {}
function C.set_instruction(p, src)
C.instructions[p] = src
end
-- --- building an activity ---------------------------------------------
-- spec = {
-- body, init model rules / initial values (UTF-8 text),
-- variables = { { name, unit, min, max, decimals }, ... },
-- iterations number of iterations (>= 1),
-- allow_switch true/false: pupils may switch between the text
-- and the graphical view; nil: only for a
-- graphical model
-- degrees true: Coach's angle unit is degrees (Sin(x)
-- takes x in degrees), else radians
-- instruction optional { html = ..., text = ... } for Coach's
-- instruction window (see instruction_html)
-- }
function C.build(spec)
if not C.template then
error("numodel-coach: cannot find numodel-coach-template.lua")
end
local tree = deep_copy(C.template)
local items = tree.items
local desc = find(items, "Description")[4]
-- Switching between text and graphical view: by default on for a
-- graphical model, off for a text model (Coach derives the text
-- from the graphical model, so a text-only model would be lost).
local switch = spec.allow_switch
if switch == nil then switch = spec.graphic_xml ~= nil end
find_leaf(desc, "CanSwitchModelModes")[5] = string.char(switch and 1 or 0)
find_leaf(desc, "AngleUnits")[5] = spec.degrees and "\1" or "\0"
-- Mode: 1 text, 0 graphical.
if spec.graphic_xml then
find_leaf(find(items, "GrModMain")[4], "Mode")[5] = "\0"
else
find_leaf(find(items, "GrModMain")[4], "Mode")[5] = "\1"
end
if spec.instruction then
find(items, "NewText\0Instructie")[4] = long_text(spec.instruction.text)
find(items, "HTMLText\0Instructie")[4] = long_text(spec.instruction.html)
end
find(items, "ModelBody")[4] = long_text(spec.body)
find(items, "ModelInit")[4] = long_text(spec.init)
local vl = { leaf("Number", 2, string.pack(" has no other role.
local xml_rec = find(items, "ModelXML")
if spec.graphic_xml then
-- Graphical: the objects carry the model; Coach derives the
-- variable list from them, as in CMA's own activities.
find(items, "VarList")[4] =
{ leaf("Number", 2, string.pack("[^<]*" .. tag .. ">",
"<" .. tag .. ">" .. val .. "" .. tag .. ">", 1)
end
set("start", "0"); set("stop", tostring(n - 1)); set("step", "1")
xml_rec[4] = long_text(xml)
return C.serialise(tree)
end
-- --- graphical model (mode=graphic) -----------------------------------
-- Coach keeps a graphical model as XML in ModelXML: one
-- per object -- Independent (the time), Constant, Aux, State (a stock),
-- Flow (between two states, or a state and a cloud) and Relation (an
-- arrow from a variable to a formula that uses it) -- with its
-- formula, unit and position. CMA's activities (and Coach on
-- switching) derive the text model from it. The XML below follows
-- what Coach 7.0 itself writes.
local function xml_esc(s)
return (tostring(s or ""):gsub("[&<>]", { ["&"] = "&",
["<"] = "<", [">"] = ">" }))
end
-- The fields every object has, in Coach's order.
local function object_xml(tag, o)
local open = tag == "Aux"
and ''
or '<' .. tag .. ' locked="false">'
local L = {
" ", " " .. open,
" ",
" " .. (o.color or 16711680) .. "",
" " .. math.floor(o.x + 0.5) .. "",
" " .. math.floor(o.y + 0.5) .. "",
" Segoe UI",
" 12",
" " .. xml_esc(o.unit) .. "",
" ",
" 2",
" " .. tostring(o.showdisplay ~= false) .. "",
" " .. tostring(o.showlabel ~= false) .. "",
" south",
" " .. tostring(o.condition ~= nil) .. "",
" " .. xml_esc(o.condition) .. "",
" " .. xml_esc(o.condtrue) .. "",
" " .. xml_esc(o.condfalse) .. "",
" " .. tostring(o.init ~= nil) .. "",
" " .. xml_esc(o.expr) .. "",
" " .. xml_esc(o.init) .. "",
" false",
" false",
" false",
" false",
" 16711680",
" ",
" " .. xml_esc(o.min or 0) .. "",
" " .. xml_esc(o.max or 10) .. "",
" true",
" false",
}
for _, e in ipairs(o.extra or {}) do L[#L + 1] = " " .. e end
L[#L + 1] = " " .. tag .. ">"
L[#L + 1] = " "
return table.concat(L, "\r\n")
end
-- Where a line from the centre of object o towards (tx, ty) leaves
-- it: a state is a 42 x 26 rectangle, a valve a small circle, the
-- others a circle of radius 11.
local function edge(o, tx, ty)
local dx, dy = tx - o.x, ty - o.y
local d = math.sqrt(dx * dx + dy * dy)
if d == 0 then return o.x, o.y end
if o.tag == "State" then
local f = math.min(21 / math.max(math.abs(dx), 1e-9),
13 / math.max(math.abs(dy), 1e-9))
return o.x + dx * f, o.y + dy * f
end
local r = o.tag == "Flow" and 6 or 11
return o.x + dx * r / d, o.y + dy * r / d
end
local function num_text(x)
-- A number in Coach's notation: decimal comma, no binary noise.
local s = numodel.tex_number(x)
return (s:gsub("%.", ","))
end
-- Build the XML for numodel prefix p. r = numodel.plaintext's result
-- (names), g = numodel.get_model(p). Returns the XML, or nil and the
-- reasons why the model cannot be drawn.
function C.graphic_xml(p, r, g, opts, note)
local names = r.names
local open = (opts.blanks or numodel.settings.blanks) ~= "filled"
local D = numodel.dialects.NL
local function pe(e)
local s, ws = numodel.plain_expr(e, names, D)
for _, w in ipairs(ws) do note(w) end
return s
end
local var = {}
for _, v in ipairs(g.vars) do var[v.name] = v end
local problems = {}
local function problem(s) problems[#problems + 1] = s end
-- Rules per target, and the stop condition.
local rules, stop = {}, nil
for _, row in ipairs(g.program) do
if row.kind == "calc" or row.kind == "ternary" then
if rules[row.target] then
problem(names[row.target] .. " has more than one rule")
end
rules[row.target] = row
elseif row.kind == "stop" then stop = row
elseif row.kind == "text" then
note("free-text row left out of the graphical model")
end
end
-- The time: t := t + dt.
local time, dt
for name, row in pairs(rules) do
local refs = numodel.expr_refs(row.expr)
if row.kind == "calc" and var[name].type == "system" then
for other in pairs(refs) do
if other ~= name then
local terms = numodel.plain_flow_terms(row.expr, name,
other, names, D)
if terms and #terms == 1 and terms[1].sign == 1
and terms[1].expr == "1" then
time, dt = name, other
end
end
end
end
end
if not time then
return nil, { "no time rule t := t + dt (a system variable)" }
end
-- Classify: State (stock rule with flows), Aux (other rule),
-- Constant (no rule).
local kind, flows_of = {}, {}
for _, v in ipairs(g.vars) do
local n, row = v.name, rules[v.name]
if n == time or n == dt then
elseif not row then kind[n] = "Constant"
elseif row.kind == "ternary" then
if numodel.expr_refs(row.expr)[dt] then
problem("the conditional rule for " .. names[n]
.. " uses dt (a conditional stock is not drawn)")
end
kind[n] = "Aux"
else
local terms = numodel.plain_flow_terms(row.expr, n, dt, names, D)
if terms then
kind[n] = "State"; flows_of[n] = terms
elseif numodel.expr_refs(row.expr)[n] then
local _, why = numodel.plain_flow_terms(row.expr, n, dt, names, D)
problem("the rule for " .. names[n] .. " uses " .. names[n]
.. " itself but is no stock rule: " .. why)
else
kind[n] = "Aux"
end
end
end
if #problems > 0 then return nil, problems end
-- edu: every variable an object of its own, the flows Stroom_1, ...
-- with the variable as formula (\graphicmodel's edu style);
-- tight: a flow that is one variable takes its name and formula,
-- as \graphicmodel's tight style draws it as the valve.
local tight = opts.graphic_style == "tight"
-- How many formulas use each variable (tight: a variable used only
-- in its flow becomes the flow).
local uses = {}
local function count(e)
for x in pairs(numodel.expr_refs(e)) do uses[x] = (uses[x] or 0) + 1 end
end
for n, row in pairs(rules) do if n ~= time then count(row.expr) end end
for _, v in ipairs(g.vars) do
if v.has_start and v.value_expr then count(v.value_expr) end
end
if stop then count(stop.expr) end
-- Flows: a term -X of stock A and +X of stock B is one flow A -> B.
local flows = {}
local pending = {}
for _, v in ipairs(g.vars) do
for _, t in ipairs(flows_of[v.name] or {}) do
local hit
for i, f in ipairs(pending) do
if f.expr == t.expr and f.sign == -t.sign and f.stock ~= v.name then
hit = i; break
end
end
if hit then
local f = table.remove(pending, hit)
local from = f.sign < 0 and f.stock or v.name
local to = f.sign < 0 and v.name or f.stock
flows[#flows + 1] = { from = from, to = to, expr = t.expr,
refs = t.refs }
else
pending[#pending + 1] = { stock = v.name, sign = t.sign,
expr = t.expr, refs = t.refs }
end
end
end
for _, f in ipairs(pending) do
flows[#flows + 1] = { from = f.sign < 0 and f.stock or nil,
to = f.sign > 0 and f.stock or nil, expr = f.expr, refs = f.refs }
end
-- Compact: a flow that is one variable used nowhere else -- an
-- auxiliary (also a conditional one) or a constant -- takes its
-- name and formula (or value).
local absorbed = {}
for _, f in ipairs(tight and flows or {}) do
local only, nrefs = nil, 0
for x in pairs(f.refs) do only, nrefs = x, nrefs + 1 end
-- used in the stock rule(s) of this flow only: one, or two for
-- a flow between two stocks
local n_rules = (f.from and f.to) and 2 or 1
if nrefs == 1 and uses[only] == n_rules and names[only] == f.expr then
local v, row = var[only], rules[only]
if kind[only] == "Aux" and not v.has_start then
f.var = only
f.refs = numodel.expr_refs(row.expr)
if row.kind == "ternary" then
local c, a, b, ws = numodel.plain_ternary(row.expr, names, D)
for _, w in ipairs(ws) do note(w) end
f.cond = { c, a, b }
f.expr = ""
else
f.expr = pe(row.expr)
end
elseif kind[only] == "Constant" and v.has_start then
f.var, f.const = only, true
f.refs = numodel.expr_refs(v.value_expr)
f.expr = pe(v.value_expr)
end
if f.var then absorbed[only] = f end
end
end
-- Positions: the grid of \graphicmodel in the same style -- edu:
-- every variable a node of its own, the valve of a flow variable
-- beside it; tight: the flow variable is the valve -- turned into
-- Coach's pixels.
numodel.compute_layout(p, tight and "tight" or "edu")
local M = numodel.models[p]
local maxgy = 0
for _, L in pairs(M.layout or {}) do
if (L.gridy or -1) > maxgy then maxgy = L.gridy end
end
local function px(gx, gy)
return 90 + gx * 110, 80 + (maxgy - gy) * 90
end
local objs, by_var = {}, {}
local function add(o)
objs[#objs + 1] = o
if o.var then by_var[o.var] = o end
return o
end
local function blank_rule(n) return open and rules[n] and rules[n].blank end
local function blank_start(n) return open and var[n].blank end
local function base(n)
local v = var[n]
local u = numodel.plain_unit(v.unit)
return { var = n, label = names[n], unit = u,
min = v.axis_min and num_text(v.axis_min),
max = v.axis_max and num_text(v.axis_max) }
end
local tv = var[time]
local ind = base(time)
ind.tag, ind.x, ind.y, ind.expr, ind.showdisplay = "Independent", 20, 20,
tv.has_start and pe(tv.value_expr) or "0", false
ind.extra = { "false" }
add(ind)
-- dt as a constant when a formula still uses it once the factors
-- dt of the stock rules have gone into the flows.
local need_dt = stop and numodel.expr_refs(stop.expr)[dt]
for n, row in pairs(rules) do
if n ~= time and kind[n] ~= "State"
and numodel.expr_refs(row.expr)[dt] then need_dt = true end
end
for _, v in ipairs(g.vars) do
if v.has_start and v.value_expr
and numodel.expr_refs(v.value_expr)[dt] then need_dt = true end
end
for _, f in ipairs(flows) do if f.refs[dt] then need_dt = true end end
if need_dt then
local o = base(dt)
o.tag, o.expr = "Constant", pe(var[dt].value_expr)
o.x, o.y = 20, 60
add(o)
end
for _, v in ipairs(g.vars) do
local n = v.name
if kind[n] and not absorbed[n] then
local L = M.layout and M.layout[n] or {}
local o = base(n)
o.tag = kind[n]
o.x, o.y = px(L.gridx or 0, L.gridy or 0)
-- Compact: a valve of \graphicmodel that stays an object of
-- its own goes to the row above when that cell is free, else
-- half a row up.
if tight and L.role and L.role:find("valve") then
local up = string.format("%d,%d", L.gridx, L.gridy + 1)
o.y = o.y - ((M.occupied or {})[up] and 45 or 90)
end
if kind[n] == "Constant" or kind[n] == "State" then
o.expr = blank_start(n) and "" or
(v.has_start and pe(v.value_expr) or "0")
if not v.has_start then
note(names[n] .. " has no start value; 0 in Coach")
end
elseif rules[n].kind == "ternary" then
local c, a, b, ws = numodel.plain_ternary(rules[n].expr, names, D)
for _, w in ipairs(ws) do note(w) end
if blank_rule(n) then c, a, b = "", "", "" end
o.condition, o.condtrue, o.condfalse, o.expr = c, a, b, ""
else
o.expr = blank_rule(n) and "" or pe(rules[n].expr)
end
if kind[n] == "Aux" and v.has_start then
o.init = blank_start(n) and "" or pe(v.value_expr)
end
add(o)
end
end
-- Flows: the valve beside its stock, or between two stocks.
local side = {}
for k, f in ipairs(flows) do
local o
if f.var then
-- tight: the flow is the variable, its name shown
o = base(f.var)
o.showlabel = true
o.expr = f.expr
if f.cond then
o.condition, o.condtrue, o.condfalse =
f.cond[1], f.cond[2], f.cond[3]
end
if f.const and blank_start(f.var)
or not f.const and blank_rule(f.var) then
o.expr = ""
if f.cond then o.condition, o.condtrue, o.condfalse = "", "", "" end
end
else
o = { label = "Stroom_" .. k, unit = "", showlabel = false,
expr = f.expr }
end
o.tag = "Flow"
local A, B = f.from and by_var[f.from], f.to and by_var[f.to]
if f.from and blank_rule(f.from) or f.to and blank_rule(f.to) then
o.expr = ""
end
local S = B or A
local key = S.var .. (B and "in" or "out")
side[key] = (side[key] or 0) + 1
local dy = (side[key] - 1) * 30
-- The valve of \graphicmodel, when the flow is one variable.
local only
for x in pairs(f.var and {} or f.refs) do
only = only == nil and x or false
end
local VL = M.layout and M.layout[f.var or only or ""]
if not tight and VL and VL.vpos_x then
o.x, o.y = px(VL.vpos_x, VL.vpos_y or 0)
elseif tight and VL and VL.role and VL.role:find("valve")
and VL.gridx then
o.x, o.y = px(VL.gridx, VL.gridy)
elseif A and B then
o.x, o.y = (A.x + B.x) / 2, (A.y + B.y) / 2 + dy
elseif B then
-- the cell numodel keeps free for the valve of a stock
o.x, o.y = B.x - 110, B.y + dy
else
o.x, o.y = A.x + 110, A.y + dy
end
local x1, y1, x2, y2
if A then x1, y1 = edge(A, o.x, o.y) else x1, y1 = o.x - 40, o.y end
if B then x2, y2 = edge(B, o.x, o.y) else x2, y2 = o.x + 40, o.y end
o.extra = {
"" .. xml_esc(A and A.label or "") .. "",
"" .. math.floor(x1 + 0.5) .. "",
"" .. math.floor(y1 + 0.5) .. "",
"" .. xml_esc(B and B.label or "") .. "",
"" .. math.floor(x2 + 0.5) .. "",
"" .. math.floor(y2 + 0.5) .. "",
"0",
"" .. math.floor(o.x + 0.5) .. "",
"" .. math.floor(o.y + 0.5) .. "",
}
o.refs = f.refs
o.ends = { [A or 0] = true, [B or 0] = true }
add(o)
if f.var then by_var[f.var] = o end
end
-- Relations: from every variable a formula uses to its object.
local rel = 0
local relations = {}
-- A straight arrow that would pass close by another object is bent
-- (above it, or to its left for a vertical arrow), as the curved
-- causal arrows of \graphicmodel.
local function blocked(src, dst)
-- from a stock to its own flow: along the pipe
if dst.ends and dst.ends[src] then return true end
local ax, ay, bx, by = src.x, src.y, dst.x, dst.y
local lx, ly = bx - ax, by - ay
local l2 = lx * lx + ly * ly
if l2 == 0 then return false end
for _, o in ipairs(objs) do
if o ~= src and o ~= dst then
local t = ((o.x - ax) * lx + (o.y - ay) * ly) / l2
if t > 0.05 and t < 0.95 then
local qx, qy = ax + t * lx - o.x, ay + t * ly - o.y
if qx * qx + qy * qy < 18 * 18 then return true end
end
end
end
return false
end
local function relate(src, dst)
if not src or not dst or src == dst then return end
rel = rel + 1
local ox, oy = 0, 0
if blocked(src, dst) then
local lx, ly = dst.x - src.x, dst.y - src.y
local l = math.sqrt(lx * lx + ly * ly)
ox, oy = ly / l * 45, -lx / l * 45 -- a normal
if oy > 0 or (oy == 0 and ox > 0) then ox, oy = -ox, -oy end
end
local c1x = src.x + (dst.x - src.x) / 3 + ox
local c1y = src.y + (dst.y - src.y) / 3 + oy
local c2x = src.x + 2 * (dst.x - src.x) / 3 + ox
local c2y = src.y + 2 * (dst.y - src.y) / 3 + oy
local x1, y1 = edge(src, c1x, c1y)
local x2, y2 = edge(dst, c2x, c2y)
if ox == 0 and oy == 0 then
c1x, c1y = x1 + (x2 - x1) / 3, y1 + (y2 - y1) / 3
c2x, c2y = x1 + 2 * (x2 - x1) / 3, y1 + 2 * (y2 - y1) / 3
end
relations[#relations + 1] = {
tag = "Relation", label = "Relatie_" .. rel, color = 255,
x = (x1 + x2) / 2 + 0.75 * ox, y = (y1 + y2) / 2 + 0.75 * oy,
showlabel = false, unit = "", expr = "",
extra = {
"" .. xml_esc(src.label) .. "",
"" .. math.floor(x1 + 0.5) .. "",
"" .. math.floor(y1 + 0.5) .. "",
"" .. xml_esc(dst.label) .. "",
"" .. math.floor(x2 + 0.5) .. "",
"" .. math.floor(y2 + 0.5) .. "",
"", " " .. math.floor(c1x + 0.5) .. "",
" " .. math.floor(c1y + 0.5) .. "", "",
"", " " .. math.floor(c2x + 0.5) .. "",
" " .. math.floor(c2y + 0.5) .. "", "",
},
}
end
for _, o in ipairs(objs) do
local refs = {}
local function merge(e)
if e then for x in pairs(numodel.expr_refs(e)) do refs[x] = true end end
end
if o.tag == "Flow" then
for x in pairs(o.refs or {}) do refs[x] = true end
elseif o.var and o.var ~= time then
if rules[o.var] and kind[o.var] == "Aux" then merge(rules[o.var].expr) end
if var[o.var].has_start then merge(var[o.var].value_expr) end
end
local list = {}
for x in pairs(refs) do list[#list + 1] = x end
table.sort(list)
for _, x in ipairs(list) do
if x ~= o.var then relate(by_var[x], o) end
end
end
-- Settings: time from the start of t, step dt, at most maxiter
-- steps; the stop condition ends the run.
local t0 = tv.value or 0
local step = var[dt].value or 1
local L = {
'',
" " .. num_text(t0) .. "",
" " .. num_text(t0 + step * g.maxiter) .. "",
" " .. num_text(step) .. "",
" Euler",
" difference",
" " .. tostring(stop ~= nil) .. "",
" " .. xml_esc(stop and not (open and stop.blank)
and pe(stop.expr) or "") .. "",
" 1",
" false",
}
for _, o in ipairs(objs) do L[#L + 1] = object_xml(o.tag, o) end
for _, o in ipairs(relations) do L[#L + 1] = object_xml("Relation", o) end
L[#L + 1] = ""
return table.concat(L, "\r\n") .. "\r\n"
end
-- --- from a numodel model ----------------------------------------------
-- Build the activity for numodel prefix p. Returns the file contents
-- and the list of warnings (things that could not be exported
-- exactly). opts.allow_switch as in build.
function C.from_model(p, opts)
opts = opts or {}
local g = numodel.get_model(p)
if not g then error("numodel-coach: unknown model prefix '" .. p .. "'") end
-- opts.blanks: "open"/"filled", or nil to follow numodel's setting.
local r = numodel.plaintext(p, { dialect = "NL", blanks = opts.blanks })
local W = {}
for _, w in ipairs(r.warnings) do W[#W + 1] = w end
local vars = {}
for _, v in ipairs(g.vars) do
local unit, w = numodel.plain_unit(v.unit)
-- Units of variables with a start value were already checked
-- by plaintext (they appear as comments there).
if w and not v.has_start then W[#W + 1] = w end
-- Coach's axis range for the variable: the range of its
-- graph in the document (\diagrammodel), else Coach's 0..10.
vars[#vars + 1] = { name = r.names[v.name], unit = unit,
min = v.axis_min, max = v.axis_max }
end
local graphic_xml
if opts.mode == "graphic" then
local why
graphic_xml, why = C.graphic_xml(p, r, g, opts,
function(w) W[#W + 1] = w end)
if not graphic_xml then
W[#W + 1] = "no graphical model: " .. table.concat(why, "; ")
.. "; written as a text model"
end
end
local instruction
if C.instructions[p] then
instruction = C.instruction_html(C.instructions[p], ",",
function(w) W[#W + 1] = w end)
end
local data = C.build({
body = r.body, init = r.init, variables = vars,
iterations = g.maxiter, allow_switch = opts.allow_switch,
degrees = numodel.settings.angles == "degrees",
instruction = instruction, graphic_xml = graphic_xml,
})
return data, W
end
-- Write the activity for prefix p to path, creating the directory if
-- needed. Returns the list of warnings.
function C.write(p, path, opts)
local data, W = C.from_model(p, opts)
local dir = path:match("^(.*)/[^/]*$")
if dir and dir ~= "" and lfs and not lfs.isdir(dir) then
local acc = path:sub(1, 1) == "/" and "" or nil
for part in dir:gmatch("[^/]+") do
acc = acc and (acc .. "/" .. part) or part
if not lfs.isdir(acc) then lfs.mkdir(acc) end
end
end
local f, err = io.open(path, "wb")
if not f then error("numodel-coach: cannot write " .. path .. ": " .. tostring(err)) end
f:write(data)
f:close()
return W
end
-- Called by \coachmodel: write the file and hand every warning to
-- TeX as \numodelcoachwarn{prefix}{path}{text}. The text is passed
-- with catcode "other" (tex.sprint -2), so braces or backslashes in it
-- are harmless.
function C.tex_write(p, path, allow_switch, blanks, mode, graphic_style)
if not numodel.get_model(p) then
tex.sprint("\\numodelcoachnomodel{")
tex.sprint(-2, p)
tex.sprint("}")
return
end
if blanks == "" then blanks = nil end
-- "true", "false", or "" for the default of the mode
if allow_switch == "" then allow_switch = nil
elseif type(allow_switch) == "string" then
allow_switch = allow_switch == "true"
end
local W = C.write(p, path, { allow_switch = allow_switch, blanks = blanks,
mode = mode, graphic_style = graphic_style })
for _, w in ipairs(W) do
tex.sprint("\\numodelcoachwarn{")
tex.sprint(-2, p)
tex.sprint("}{")
tex.sprint(-2, path)
tex.sprint("}{")
tex.sprint(-2, w)
tex.sprint("}")
end
tex.sprint("\\numodelcoachwritten{")
tex.sprint(-2, path)
tex.sprint("}")
end
-- Readable summary of a written file (tests, debugging): the parts
-- numodel-coach fills in.
function C.dump(data)
local tree = C.parse(data)
local items, out = tree.items, {}
local function utf16(s)
local cps, i = {}, 1
while i + 1 <= #s do
cps[#cps + 1] = string.unpack("([^<]*)<")
end
out[#out + 1] = "Instruction text:"
out[#out + 1] = utf16(find(items, "NewText\0Instructie")[4][2][5]:sub(5))
out[#out + 1] = "Instruction HTML:"
out[#out + 1] = utf16(find(items, "HTMLText\0Instructie")[4][2][5]:sub(5))
out[#out + 1] = "ModelBody:"
out[#out + 1] = utf16(find(items, "ModelBody")[4][2][5]:sub(5))
out[#out + 1] = "ModelInit:"
out[#out + 1] = utf16(find(items, "ModelInit")[4][2][5]:sub(5))
out[#out + 1] = "VarList:"
for _, it in ipairs(find(items, "VarList")[4]) do
local name, typ, v = it[2], it[4], it[5]
if not name:find("_uuuu$") then
local s
if typ == 6 then s = utf16(v)
elseif typ == 4 then s = v
elseif typ == 2 then s = tostring(string.unpack("