Initial Commit
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
# =====================================================================
|
||||
# owuitools-octave - the code execution sandbox.
|
||||
#
|
||||
# This is the only container in the suite that runs code supplied by
|
||||
# whoever is talking to the model. It therefore gets the strictest
|
||||
# treatment in docker-compose.yml: network_mode "none", read-only root,
|
||||
# non-root user, all capabilities dropped.
|
||||
#
|
||||
# It has NO network stack at all, so it cannot be given work over HTTP.
|
||||
# Jobs arrive as files on a small tmpfs volume shared with the API
|
||||
# container, and results go back the same way. See run.sh.
|
||||
# =====================================================================
|
||||
FROM debian:12-slim
|
||||
|
||||
# octave-* forge packages cover the four toolboxes people actually reach
|
||||
# for in general engineering work. They are NOT auto-loaded: run.sh does
|
||||
# the `pkg load` calls, because a script that says `pkg load signal`
|
||||
# itself is valid Octave but is not valid MATLAB, and we do not want the
|
||||
# model learning to write it.
|
||||
RUN apt-get update \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
octave \
|
||||
octave-signal \
|
||||
octave-control \
|
||||
octave-statistics \
|
||||
octave-optim \
|
||||
gnuplot-nox \
|
||||
ghostscript \
|
||||
fonts-freefont-otf \
|
||||
fonts-dejavu-core \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Path shim, deliberately outside any working directory so it cannot show
|
||||
# up as a user artefact or be shadowed by a user file. See shim/print.m.
|
||||
# Octave never tells gnuplot to fill the legend background, so plot lines
|
||||
# show through it. See the script for why this is a build-time edit.
|
||||
COPY patch-legend-opaque.sh /tmp/patch-legend-opaque.sh
|
||||
RUN sh /tmp/patch-legend-opaque.sh && rm /tmp/patch-legend-opaque.sh
|
||||
|
||||
COPY shim/ /opt/owuitools-shim/
|
||||
# The shims implement MATLAB functions this Octave does not have, on a
|
||||
# path that sits ahead of everything. Two things have to be true at build
|
||||
# time and cannot be checked later: that Octave has not since acquired the
|
||||
# function itself (in which case ours is deleted rather than allowed to
|
||||
# shadow it), and that each one does what MATLAB documents. The script
|
||||
# fails the build if a shim is wrong.
|
||||
COPY check-shims.sh /tmp/check-shims.sh
|
||||
RUN sh /tmp/check-shims.sh && rm /tmp/check-shims.sh
|
||||
|
||||
COPY run.sh /usr/local/bin/run.sh
|
||||
RUN chmod 0555 /usr/local/bin/run.sh
|
||||
|
||||
# Unprivileged, and with a home it can actually write to (a read-only
|
||||
# root filesystem means HOME must live on the tmpfs).
|
||||
# Same uid as owuitools-api on purpose. The two containers share one small
|
||||
# tmpfs for job files, and matching uids let that volume be mode 0700 -
|
||||
# unreadable to anything else - instead of world-writable. It grants no
|
||||
# privilege: they are separate containers with separate namespaces, and
|
||||
# the uid only decides who can touch /jobs.
|
||||
RUN useradd --uid 10001 --create-home --home-dir /home/runner --shell /usr/sbin/nologin runner
|
||||
USER 10001:10001
|
||||
ENV HOME=/tmp/octhome
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/run.sh"]
|
||||
Executable
+112
@@ -0,0 +1,112 @@
|
||||
#!/bin/sh
|
||||
# Guard the MATLAB compatibility shims at image build time.
|
||||
#
|
||||
# Two jobs, both of which have to happen before anyone can run code
|
||||
# against this image rather than after:
|
||||
#
|
||||
# 1. DROP any shim whose name Octave has since acquired. The shim
|
||||
# directory sits ahead of everything on the path, so a stale copy
|
||||
# would shadow the real implementation - our four-signature `contains`
|
||||
# would quietly replace a complete one. Octave gaining a function is
|
||||
# good news; the correct response is to delete ours, not to keep it.
|
||||
#
|
||||
# 2. TEST what survives. Each shim is asserted against the behaviour the
|
||||
# MATLAB documentation specifies - including the edge conventions that
|
||||
# are the whole reason these are not one-line aliases. A failure fails
|
||||
# the build: a wrong answer that runs is the one outcome this sandbox
|
||||
# exists to prevent.
|
||||
#
|
||||
# print.m is exempt from (1). It shadows Octave's own print deliberately
|
||||
# and permanently; see shim/print.m.
|
||||
set -e
|
||||
|
||||
SHIM_DIR=/opt/owuitools-shim
|
||||
[ -d "$SHIM_DIR" ] || { echo "check-shims: $SHIM_DIR missing"; exit 1; }
|
||||
|
||||
for f in "$SHIM_DIR"/*.m; do
|
||||
name=$(basename "$f" .m)
|
||||
case "$name" in
|
||||
print|owuitools_*) continue ;;
|
||||
esac
|
||||
# Asked WITHOUT the shim directory on the path, which is where Octave
|
||||
# starts. The answer is printed rather than returned as an exit code, so
|
||||
# that an Octave that fails for some unrelated reason cannot be read as
|
||||
# "this function exists" - an unreadable answer keeps the shim, and the
|
||||
# behaviour tests below then have the last word.
|
||||
have=$(octave --no-window-system --norc --quiet \
|
||||
--eval "printf('%d', exist('$name') > 0)" 2>/dev/null)
|
||||
case "$have" in
|
||||
1) echo "check-shims: DROPPING $name - this Octave provides it"
|
||||
rm -f "$f" ;;
|
||||
0) echo "check-shims: keeping $name (Octave has no such function)" ;;
|
||||
*) echo "check-shims: could not test $name (got '$have'), keeping it" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "check-shims: running the behaviour tests"
|
||||
octave --no-window-system --norc --quiet --eval "
|
||||
addpath('$SHIM_DIR', '-begin');
|
||||
function check(label, cond)
|
||||
if (~cond)
|
||||
error('check-shims FAILED: %s', label);
|
||||
end
|
||||
printf(' ok %s\n', label);
|
||||
end
|
||||
if (exist('contains') > 0)
|
||||
check('contains char', contains('hello world', 'o w') == true);
|
||||
check('contains miss', contains('hello', 'zz') == false);
|
||||
check('contains cell', isequal(contains({'ab','cd'}, 'c'), [false true]));
|
||||
check('contains any of', contains('abc', {'x','b'}) == true);
|
||||
check('contains IgnoreCase', contains('ABC', 'b', 'IgnoreCase', true) == true);
|
||||
end
|
||||
if (exist('strlength') > 0)
|
||||
check('strlength char', strlength('abcd') == 4);
|
||||
check('strlength cell', isequal(strlength({'a','abc'}), [1 3]));
|
||||
check('strlength empty', strlength('') == 0);
|
||||
end
|
||||
if (exist('matches') > 0)
|
||||
check('matches exact', matches('abc', 'abc') == true);
|
||||
check('matches not substring', matches('abc', 'ab') == false);
|
||||
end
|
||||
if (exist('maxk') > 0)
|
||||
check('maxk values', isequal(maxk([3 1 4 1 5], 2), [5 4]));
|
||||
check('maxk row shape', size(maxk([3 1 4], 2), 1) == 1);
|
||||
[~, i] = maxk([3 1 4 1 5], 2);
|
||||
check('maxk indices', isequal(i, [5 3]));
|
||||
check('maxk k over n', numel(maxk([1 2], 9)) == 2);
|
||||
check('maxk matrix is column-wise', isequal(maxk([1 9; 5 2], 1), [5 9]));
|
||||
end
|
||||
if (exist('mink') > 0)
|
||||
check('mink values', isequal(mink([3 1 4 1 5], 2), [1 1]));
|
||||
end
|
||||
if (exist('histcounts') > 0)
|
||||
check('histcounts counts', isequal(histcounts([1 2 2 3], [1 2 3 4]), [1 2 1]));
|
||||
check('histcounts last bin closed', isequal(histcounts([4], [1 2 3 4]), [0 0 1]));
|
||||
check('histcounts half open', isequal(histcounts([2], [1 2 3 4]), [0 1 0]));
|
||||
check('histcounts drops NaN', sum(histcounts([1 NaN], [1 2])) == 1);
|
||||
failed = false;
|
||||
try
|
||||
histcounts([1 2 3]);
|
||||
catch
|
||||
failed = true;
|
||||
end
|
||||
check('histcounts refuses auto bins', failed);
|
||||
end
|
||||
if (exist('discretize') > 0)
|
||||
check('discretize bins', isequal(discretize([1 2.5 4], [1 2 3 4]), [1 2 3]));
|
||||
check('discretize outside is NaN', isnan(discretize(9, [1 2])));
|
||||
end
|
||||
if (exist('rmmissing') > 0)
|
||||
check('rmmissing vector', isequal(rmmissing([1 NaN 3]), [1 3]));
|
||||
check('rmmissing keeps row shape', size(rmmissing([1 NaN 3]), 1) == 1);
|
||||
check('rmmissing matrix drops rows', isequal(rmmissing([1 2; NaN 4; 5 6]), [1 2; 5 6]));
|
||||
failed = false;
|
||||
try
|
||||
rmmissing({'a'});
|
||||
catch
|
||||
failed = true;
|
||||
end
|
||||
check('rmmissing refuses cells', failed);
|
||||
end
|
||||
printf('check-shims: all shim behaviour tests pass\n');
|
||||
"
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
#!/bin/sh
|
||||
# Make gnuplot fill the legend box - but only when the legend is inside the axes.
|
||||
#
|
||||
# Octave's gnuplot backend emits `set key ...` but never `set key opaque`, and
|
||||
# gnuplot's default is noopaque - so a legend drawn INSIDE the axes has a
|
||||
# transparent background and plot lines run straight through the text. On a
|
||||
# busy plot the legend becomes unreadable, which is exactly when a legend
|
||||
# matters most. The legend axes already has color [1 1 1]; Octave simply never
|
||||
# tells gnuplot to fill it.
|
||||
#
|
||||
# An OUTSIDE key needs the opposite. gnuplot packs a key below the axes flush
|
||||
# against the x-axis label - a one-pixel gap, whatever the type size - so the
|
||||
# fill lands on the label's descenders and erases the tail of every g, y and p
|
||||
# in it: "from the trigger edge" renders as "from the triaaer edae". Out there
|
||||
# the key sits on the white margin with no plot lines to hide behind it, so
|
||||
# the fill buys nothing and costs the label. `noopaque` is also stated rather
|
||||
# than left to the default, because `set key opaque` set by an earlier axes
|
||||
# persists through the rest of a multiplot stream.
|
||||
#
|
||||
# Done at image build time rather than at runtime because it edits a vendored
|
||||
# Octave library file: in the Dockerfile it is reproducible, visible in review,
|
||||
# and cannot be half-applied. It exits 0 when the anchor is absent, so an
|
||||
# Octave upgrade that moves this code degrades to the old rendering instead of
|
||||
# failing the build.
|
||||
set -e
|
||||
|
||||
ANCHOR='__do_enhanced_option__ (enhanced, hlgnd));'
|
||||
ADDED='owuitools: gnuplot defaults to a transparent key'
|
||||
found=0
|
||||
|
||||
for f in /usr/share/octave/*/m/plot/util/private/__gnuplot_draw_axes__.m; do
|
||||
[ -f "$f" ] || continue
|
||||
if grep -q "$ADDED" "$f"; then
|
||||
echo "legend-opaque: already applied to $f"
|
||||
found=1
|
||||
continue
|
||||
fi
|
||||
n=$(grep -c "$ANCHOR" "$f" || true)
|
||||
if [ "$n" != "1" ]; then
|
||||
echo "legend-opaque: anchor appears $n times in $f, skipping"
|
||||
continue
|
||||
fi
|
||||
awk -v anchor="$ANCHOR" -v added="$ADDED" '
|
||||
{ print }
|
||||
index($0, anchor) && !done {
|
||||
done = 1
|
||||
print " ## " added ", so plot lines show"
|
||||
print " ## through the legend text. The legend axes is already white."
|
||||
print " ## Outside the axes the fill has nothing to hide and lands on"
|
||||
print " ## the x-axis label instead, so there it is turned off - and"
|
||||
print " ## said out loud, because opaque persists across a multiplot."
|
||||
print " if (strcmp (inout, \"inside\"))"
|
||||
print " fputs (plot_stream, \"set key opaque;\\n\");"
|
||||
print " else"
|
||||
print " fputs (plot_stream, \"set key noopaque;\\n\");"
|
||||
print " endif"
|
||||
}
|
||||
' "$f" > "$f.owuitools" && mv "$f.owuitools" "$f"
|
||||
echo "legend-opaque: applied to $f"
|
||||
found=1
|
||||
done
|
||||
|
||||
[ "$found" = "1" ] || echo "legend-opaque: target not found, skipping"
|
||||
exit 0
|
||||
Executable
+288
@@ -0,0 +1,288 @@
|
||||
#!/bin/bash
|
||||
# =====================================================================
|
||||
# Job runner for the Octave sandbox.
|
||||
#
|
||||
# WHY FILES AND NOT HTTP
|
||||
# This container executes code supplied by whoever is talking to the
|
||||
# model, so it runs with network_mode "none" - there is no network stack
|
||||
# to serve HTTP on. Work arrives as files on a small tmpfs shared with
|
||||
# the api container:
|
||||
#
|
||||
# /jobs/in/<id>/ directory the api fills with main.m, any extra
|
||||
# .m files, and a `meta` file carrying a nonce
|
||||
# /jobs/in/<id>.ready created LAST, so its appearance means the
|
||||
# directory is complete. Without it the runner
|
||||
# races the api and executes half-written code.
|
||||
# /jobs/out/<id>.out result, moved into place atomically
|
||||
#
|
||||
# THE NONCE
|
||||
# The executed script shares this uid and can therefore see /jobs. An
|
||||
# adversarial review pointed out that this lets a script do more than the
|
||||
# denial-of-service originally documented: it could WRITE another job's
|
||||
# result file and hand that user a forged answer. So every result is
|
||||
# stamped with a per-job nonce that the api generates and checks, and
|
||||
# `meta` is deleted before Octave starts - the nonce lives only in this
|
||||
# shell's memory, and the interpreter never has anything to read it from.
|
||||
# The runner also writes the section markers itself, so a script cannot
|
||||
# emit one.
|
||||
#
|
||||
# WHAT REMAINS, stated plainly: a hostile script can still delete or read
|
||||
# another queued job, so it can cause a timeout or see someone else's
|
||||
# source. It cannot escape the container, reach the network, forge a
|
||||
# result, or read anything of the operator's. Closing the last gap needs a
|
||||
# per-job container and therefore the docker socket, which would be a far
|
||||
# worse trade than the problem.
|
||||
# =====================================================================
|
||||
set -u
|
||||
|
||||
# One line per sandboxed run, with every other component's log in ./logs.
|
||||
# `exec` rather than a tee per echo, so anything the loop reports - including
|
||||
# a job that failed in a way nobody anticipated - lands in the file too.
|
||||
LOG_DIR="${OWUITOOLS_LOG_DIR:-/logs}"
|
||||
if [ -d "$LOG_DIR" ] && [ -w "$LOG_DIR" ]; then
|
||||
exec > >(tee -a "$LOG_DIR/octave.log") 2>&1
|
||||
fi
|
||||
|
||||
JOBS=${OWUITOOLS_JOBS_DIR:-/jobs}
|
||||
DEFAULT_TIMEOUT=${OWUITOOLS_OCTAVE_TIMEOUT:-25}
|
||||
MAX_TIMEOUT=${OWUITOOLS_OCTAVE_MAX_TIMEOUT:-60}
|
||||
MAX_OUT=${OWUITOOLS_OCTAVE_MAX_OUTPUT:-200000}
|
||||
REAP_MINUTES=${OWUITOOLS_OCTAVE_REAP_MINUTES:-10}
|
||||
|
||||
mkdir -p "$JOBS/in" "$JOBS/run" "$JOBS/out" "$HOME" 2>/dev/null
|
||||
|
||||
# Wrapping the user script in try/catch is what lets us hand the model a
|
||||
# structured error with a line number instead of raw stderr, which varies
|
||||
# by Octave version and buries the location.
|
||||
#
|
||||
# `message` is emitted LAST and everything after it is the message. An
|
||||
# earlier version put it first, so an error whose text happened to contain
|
||||
# a line starting "at:" - entirely possible, Octave quotes source - was
|
||||
# truncated there and the rest parsed as a garbage stack frame.
|
||||
DRIVER='
|
||||
__ws = warning();
|
||||
warning("off", "all");
|
||||
try
|
||||
pkg load signal; pkg load control;
|
||||
pkg load statistics; pkg load optim;
|
||||
catch
|
||||
end
|
||||
try
|
||||
graphics_toolkit("gnuplot");
|
||||
catch
|
||||
end
|
||||
try
|
||||
set(0, "defaultfigurevisible", "off");
|
||||
catch
|
||||
end
|
||||
% Two MATLAB-compatibility preferences, both measured against MATLAB`s
|
||||
% documented behaviour rather than assumed:
|
||||
% print_empty_dimensions - Octave prints `x = [](0x0)` where MATLAB
|
||||
% prints `x = []`, and the model reads that output back to the user.
|
||||
% save_default_options - `save("data.mat", "x")` wrote an Octave TEXT
|
||||
% file with a `# Created by Octave` header. A user opening it in
|
||||
% MATLAB gets nothing. -mat7-binary writes a real MAT-file (verified:
|
||||
% the header reads "MATLAB 5.0 MAT-file"), which both read.
|
||||
% The rest of Octave`s --traditional set is deliberately not taken: it
|
||||
% also turns struct printing off and the terminal bell on.
|
||||
try
|
||||
print_empty_dimensions(false);
|
||||
catch
|
||||
end
|
||||
try
|
||||
save_default_options("-mat7-binary");
|
||||
catch
|
||||
end
|
||||
warning(__ws);
|
||||
clear __ws;
|
||||
% Ahead of Octave`s own print. Renders PNGs through the cairo terminal so
|
||||
% thin lines are anti-aliased instead of dropping pixels; see shim/print.m.
|
||||
% Suppressed BEFORE the addpath, not after: the warning is emitted at the
|
||||
% moment the shim joins the path, so silencing it afterwards was one line
|
||||
% too late. The shadow is deliberate and permanent, and this is the same
|
||||
% stderr the model reads for its real errors.
|
||||
warning("off", "Octave:shadowed-function");
|
||||
try
|
||||
addpath("/opt/owuitools-shim", "-begin");
|
||||
catch
|
||||
end
|
||||
more off;
|
||||
% Octave`s own parser, used as a second compatibility checker: it names
|
||||
% the Octave-only constructs in the file it is about to run - `x += 1`,
|
||||
% `!=`, `.*=`, a bare newline inside parentheses - with a line number, and
|
||||
% it is never wrong about its own dialect the way a regex can be.
|
||||
%
|
||||
% This was OFF for a long time and the comment here said it was unusable,
|
||||
% because Octave`s own library is written in the same dialect and the real
|
||||
% error disappeared under hundreds of warnings about files nobody can
|
||||
% edit. The missing piece was that every warning names its file: the
|
||||
% library ones are dropped by path in run.sh, and what is left is the
|
||||
% user`s code. Turned on LAST, after the pkg loads and the addpath, so
|
||||
% none of that machinery is parsed with it enabled.
|
||||
warning("on", "Octave:language-extension");
|
||||
try
|
||||
source("main.m");
|
||||
catch err
|
||||
fflush(stdout);
|
||||
fprintf(stderr, "\n__OWUITOOLS_ERR__\n");
|
||||
fprintf(stderr, "identifier:%s\n", err.identifier);
|
||||
if (isfield(err, "stack") && numel(err.stack) > 0)
|
||||
for k = 1:numel(err.stack)
|
||||
fprintf(stderr, "at:%s:%d\n", err.stack(k).name, err.stack(k).line);
|
||||
end
|
||||
end
|
||||
fprintf(stderr, "message:%s\n", err.message);
|
||||
fflush(stderr);
|
||||
exit(3);
|
||||
end
|
||||
fflush(stdout);
|
||||
exit(0);
|
||||
'
|
||||
|
||||
run_job() {
|
||||
local id="$1" dir="$JOBS/run/$id"
|
||||
local work timeout rc started ended nonce figure
|
||||
work="$dir/work"
|
||||
|
||||
timeout="$DEFAULT_TIMEOUT"
|
||||
nonce=""
|
||||
figure=""
|
||||
if [ -f "$dir/meta" ]; then
|
||||
local t
|
||||
t=$(sed -n 's/^TIMEOUT=\([0-9]\{1,3\}\)$/\1/p' "$dir/meta" | head -1)
|
||||
if [ -n "$t" ] && [ "$t" -ge 1 ] 2>/dev/null && [ "$t" -le "$MAX_TIMEOUT" ]; then
|
||||
timeout="$t"
|
||||
fi
|
||||
nonce=$(sed -n 's/^NONCE=\([A-Za-z0-9]\{8,64\}\)$/\1/p' "$dir/meta" | head -1)
|
||||
figure=$(sed -n 's/^FIGURE=\([A-Za-z0-9_.-]\{1,64\}\)$/\1/p' "$dir/meta" | head -1)
|
||||
fi
|
||||
|
||||
mkdir -p "$work"
|
||||
# Only .m files move into the working directory. Anything else the api
|
||||
# left there is metadata, and letting it onto the Octave path would be a
|
||||
# way to smuggle in a file the linter never saw.
|
||||
local inputs="$dir/__inputs"
|
||||
: > "$inputs"
|
||||
for f in "$dir"/*.m; do
|
||||
[ -e "$f" ] || continue
|
||||
cp "$f" "$work/"
|
||||
basename "$f" >> "$inputs"
|
||||
done
|
||||
cp "$inputs" "$work/../__inputs.keep" 2>/dev/null
|
||||
|
||||
printf '%s' "$DRIVER" > "$work/__driver.m"
|
||||
|
||||
# The nonce must not survive into anything the interpreter can read.
|
||||
rm -f "$dir/meta" "$inputs" 2>/dev/null
|
||||
|
||||
started=$(date +%s%3N)
|
||||
( cd "$work" && timeout -s KILL "$timeout" \
|
||||
octave --no-window-system --norc --no-history --quiet \
|
||||
--eval "run('__driver.m')" ) \
|
||||
> "$work/__stdout" 2> "$work/__stderr"
|
||||
rc=$?
|
||||
ended=$(date +%s%3N)
|
||||
|
||||
# Files the script itself created: what is in the working directory now,
|
||||
# minus what we put there. Newline separated, because a filename may
|
||||
# legitimately contain a comma and a comma-joined list then reads back
|
||||
# as two files that do not exist.
|
||||
local artefacts
|
||||
artefacts=$(cd "$work" && ls -1 2>/dev/null \
|
||||
| grep -v -x -F -f "$work/../__inputs.keep" \
|
||||
| grep -v -x -E '__driver\.m|__stdout|__stderr' \
|
||||
| head -20)
|
||||
|
||||
{
|
||||
echo "NONCE=$nonce"
|
||||
echo "RC=$rc"
|
||||
echo "TIMEOUT=$timeout"
|
||||
echo "DURATION_MS=$((ended - started))"
|
||||
echo "__OWUITOOLS_ARTEFACTS__"
|
||||
printf '%s\n' "$artefacts"
|
||||
echo "__OWUITOOLS_STDOUT__"
|
||||
head -c "$MAX_OUT" "$work/__stdout" 2>/dev/null
|
||||
echo
|
||||
echo "__OWUITOOLS_STDERR__"
|
||||
# Octave:language-extension is on (see the driver) so the parser tells
|
||||
# us which Octave-only constructs the USER wrote. Octave`s own library
|
||||
# is written in the same dialect and would bury that under hundreds of
|
||||
# lines about files nobody can edit, so those are dropped here, by
|
||||
# path, before anything is measured against the output cap.
|
||||
#
|
||||
# A warning is three parts - the line itself, a "called from", and its
|
||||
# indented frames - and dropping only the first left the frames behind
|
||||
# as orphans, which read like a stack trace for an error that never
|
||||
# happened. The file is matched rather than the spacing because 7.3
|
||||
# prints "offile" for "of file".
|
||||
awk '
|
||||
/^warning: Octave language extension used:.*(\/usr\/share\/octave|\/opt\/owuitools-shim)/ {
|
||||
skip = 1; next
|
||||
}
|
||||
skip && /^warning: called from[ \t]*$/ { next }
|
||||
skip && /^[ \t]+/ { next }
|
||||
skip && /^[ \t]*$/ { next }
|
||||
{ skip = 0; print }
|
||||
' "$work/__stderr" 2>/dev/null | head -c "$MAX_OUT"
|
||||
# A figure the caller asked for, base64 on one line. Open WebUI turns a
|
||||
# tool result that is a data: URI into a file shown to the user and
|
||||
# never sends the bytes to the model, so this costs no context.
|
||||
if [ -n "$figure" ] && [ -f "$work/$figure" ]; then
|
||||
sz=$(stat -c %s "$work/$figure" 2>/dev/null || echo 0)
|
||||
if [ "$sz" -gt 0 ] && [ "$sz" -le 3000000 ]; then
|
||||
echo
|
||||
echo "__OWUITOOLS_FIGURE__"
|
||||
base64 -w 0 "$work/$figure" 2>/dev/null
|
||||
echo
|
||||
fi
|
||||
fi
|
||||
} > "$JOBS/out/$id.tmp" 2>/dev/null
|
||||
|
||||
mv -f "$JOBS/out/$id.tmp" "$JOBS/out/$id.out" 2>/dev/null
|
||||
rm -rf "$dir" 2>/dev/null
|
||||
}
|
||||
|
||||
# Anything left over from a previous container start would otherwise wedge
|
||||
# its caller until timeout.
|
||||
rm -rf "${JOBS:?}/run"/* 2>/dev/null
|
||||
|
||||
reap_counter=0
|
||||
|
||||
# `claimed`, not `found`: a job we can SEE but cannot CLAIM - a permissions
|
||||
# problem on the volume, say - would otherwise spin this loop at full CPU
|
||||
# forever, because the marker never disappears.
|
||||
while true; do
|
||||
claimed=0
|
||||
for ready in "$JOBS/in"/*.ready; do
|
||||
[ -e "$ready" ] || continue
|
||||
id=$(basename "$ready" .ready)
|
||||
case "$id" in
|
||||
*[!A-Za-z0-9_-]*) rm -f "$ready" 2>/dev/null; continue ;;
|
||||
esac
|
||||
if mv "$JOBS/in/$id" "$JOBS/run/$id" 2>/dev/null; then
|
||||
rm -f "$ready" 2>/dev/null
|
||||
claimed=1
|
||||
# Touched here as well as below: the runner is serial, and a long
|
||||
# job used to starve the heartbeat and report the container
|
||||
# unhealthy in exactly the case the healthcheck exists to survive.
|
||||
touch "$JOBS/.heartbeat" 2>/dev/null
|
||||
run_job "$id"
|
||||
else
|
||||
rm -f "$ready" 2>/dev/null
|
||||
fi
|
||||
done
|
||||
|
||||
# Nothing else reclaims these. A result the api gave up waiting for, or
|
||||
# a job directory whose .ready never arrived, would sit on a 64 MB tmpfs
|
||||
# until the container restarted.
|
||||
reap_counter=$((reap_counter + 1))
|
||||
if [ "$reap_counter" -ge 400 ]; then
|
||||
reap_counter=0
|
||||
find "$JOBS/out" -maxdepth 1 -type f -mmin "+$REAP_MINUTES" -delete 2>/dev/null
|
||||
find "$JOBS/in" -maxdepth 1 -mmin "+$REAP_MINUTES" \
|
||||
\( -type f -o -type d \) ! -path "$JOBS/in" -exec rm -rf {} + 2>/dev/null
|
||||
fi
|
||||
|
||||
touch "$JOBS/.heartbeat" 2>/dev/null
|
||||
[ "$claimed" -eq 1 ] || sleep 0.15
|
||||
done
|
||||
@@ -0,0 +1,54 @@
|
||||
function tf = contains (str, pat, varargin)
|
||||
%CONTAINS True if a pattern occurs in a string (MATLAB R2016b+).
|
||||
%
|
||||
% One of the owuitools compatibility shims. This Octave has no
|
||||
% `contains`, so a perfectly ordinary MATLAB line failed here and the
|
||||
% model spent calls rediscovering `strfind`. The rule for everything in
|
||||
% this directory: implement the documented behaviour for the forms that
|
||||
% are actually written, and raise a clear error for the rest. A shim
|
||||
% that quietly does something *close to* MATLAB is worse than one that
|
||||
% is not there, because the run then certifies the wrong answer.
|
||||
%
|
||||
% Text here means a char row vector or a cell array of them - this
|
||||
% Octave has no string type, and pretending otherwise is exactly the
|
||||
% quiet difference this file exists to avoid.
|
||||
|
||||
if (nargin < 2)
|
||||
error ('contains: not enough input arguments');
|
||||
end
|
||||
|
||||
ignore_case = false;
|
||||
if (numel (varargin) == 2 && ischar (varargin{1}) ...
|
||||
&& strcmpi (varargin{1}, 'IgnoreCase'))
|
||||
ignore_case = logical (varargin{2});
|
||||
elseif (~isempty (varargin))
|
||||
error ('contains: the only supported option is ''IgnoreCase''');
|
||||
end
|
||||
|
||||
strs = owuitools_text_cell (str, 'contains', 'str');
|
||||
pats = owuitools_text_cell (pat, 'contains', 'pat');
|
||||
if (isempty (pats))
|
||||
error ('contains: pat must not be empty');
|
||||
end
|
||||
|
||||
tf = false (size (strs));
|
||||
for i = 1:numel (strs)
|
||||
s = strs{i};
|
||||
for j = 1:numel (pats)
|
||||
p = pats{j};
|
||||
if (ignore_case)
|
||||
hit = ~isempty (strfind (lower (s), lower (p)));
|
||||
else
|
||||
hit = ~isempty (strfind (s, p));
|
||||
end
|
||||
if (hit)
|
||||
tf(i) = true;
|
||||
break;
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if (ischar (str))
|
||||
tf = tf(1);
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,38 @@
|
||||
function bins = discretize (x, edges, varargin)
|
||||
%DISCRETIZE Which bin each value falls in, for explicit edges (R2015a+).
|
||||
%
|
||||
% An owuitools compatibility shim; see contains.m for the policy, and
|
||||
% histcounts.m for why explicit edges only and for the edge convention,
|
||||
% which this shares: the last bin is closed, everything outside the
|
||||
% edges is NaN, and so is NaN itself.
|
||||
|
||||
if (nargin < 2)
|
||||
error ('discretize: not enough input arguments');
|
||||
end
|
||||
if (~isnumeric (edges) || isscalar (edges))
|
||||
error (['discretize: this sandbox supports discretize(x, edges) with explicit ' ...
|
||||
'edges only - MATLAB''s automatic binning is not reproduced here.']);
|
||||
end
|
||||
if (~isempty (varargin))
|
||||
error ('discretize: this shim takes only (x, edges) - no categorical or option arguments');
|
||||
end
|
||||
if (~isnumeric (x) && ~islogical (x))
|
||||
error ('discretize: x must be numeric');
|
||||
end
|
||||
edges = edges(:).';
|
||||
if (numel (edges) < 2 || any (diff (edges) <= 0))
|
||||
error ('discretize: edges must be a vector of at least two increasing values');
|
||||
end
|
||||
|
||||
v = double (x);
|
||||
bins = nan (size (v));
|
||||
last = numel (edges) - 1;
|
||||
for i = 1:last
|
||||
if (i < last)
|
||||
in = v >= edges(i) & v < edges(i + 1);
|
||||
else
|
||||
in = v >= edges(i) & v <= edges(i + 1);
|
||||
end
|
||||
bins(in) = i;
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,47 @@
|
||||
function [n, edges] = histcounts (x, edges, varargin)
|
||||
%HISTCOUNTS Bin counts for explicit edges (MATLAB R2014b+).
|
||||
%
|
||||
% An owuitools compatibility shim; see contains.m for the policy.
|
||||
%
|
||||
% Explicit edges only, and that limit is the whole point: MATLAB's
|
||||
% automatic binning picks the number of bins by a documented but
|
||||
% non-trivial rule, and a shim that guessed a different one would hand
|
||||
% back a different histogram without anyone noticing. Called without
|
||||
% edges, this says so instead.
|
||||
%
|
||||
% Edge convention, which is where histcounts and the older `histc`
|
||||
% differ and why this is not a one-line alias: bin i is
|
||||
% [edges(i), edges(i+1)) and the LAST bin is closed, so a value exactly
|
||||
% on the final edge lands in the last bin rather than in one of its own.
|
||||
|
||||
if (nargin < 1)
|
||||
error ('histcounts: not enough input arguments');
|
||||
end
|
||||
if (nargin < 2 || ~isnumeric (edges) || isscalar (edges))
|
||||
error (['histcounts: this sandbox supports histcounts(x, edges) with explicit ' ...
|
||||
'edges only. MATLAB''s automatic bin selection is not reproduced here - ' ...
|
||||
'choose the edges, e.g. histcounts(x, linspace(min(x), max(x), 11)), and ' ...
|
||||
'keep whatever form you want in the code you hand over.']);
|
||||
end
|
||||
if (~isempty (varargin))
|
||||
error ('histcounts: this shim takes only (x, edges) - no name/value options');
|
||||
end
|
||||
if (~isnumeric (x) && ~islogical (x))
|
||||
error ('histcounts: x must be numeric');
|
||||
end
|
||||
edges = edges(:).';
|
||||
if (numel (edges) < 2 || any (diff (edges) <= 0))
|
||||
error ('histcounts: edges must be a vector of at least two increasing values');
|
||||
end
|
||||
|
||||
v = double (x(:));
|
||||
v = v(~isnan (v));
|
||||
n = zeros (1, numel (edges) - 1);
|
||||
for i = 1:numel (n)
|
||||
if (i < numel (n))
|
||||
n(i) = sum (v >= edges(i) & v < edges(i + 1));
|
||||
else
|
||||
n(i) = sum (v >= edges(i) & v <= edges(i + 1));
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,41 @@
|
||||
function tf = matches (str, pat, varargin)
|
||||
%MATCHES True where text equals a pattern exactly (MATLAB R2019b+).
|
||||
%
|
||||
% An owuitools compatibility shim; see contains.m for the policy.
|
||||
% Unlike `contains` this is whole-string equality, which is the point
|
||||
% of it - the two are easy to confuse and MATLAB keeps them apart.
|
||||
|
||||
if (nargin < 2)
|
||||
error ('matches: not enough input arguments');
|
||||
end
|
||||
|
||||
ignore_case = false;
|
||||
if (numel (varargin) == 2 && ischar (varargin{1}) ...
|
||||
&& strcmpi (varargin{1}, 'IgnoreCase'))
|
||||
ignore_case = logical (varargin{2});
|
||||
elseif (~isempty (varargin))
|
||||
error ('matches: the only supported option is ''IgnoreCase''');
|
||||
end
|
||||
|
||||
strs = owuitools_text_cell (str, 'matches', 'str');
|
||||
pats = owuitools_text_cell (pat, 'matches', 'pat');
|
||||
|
||||
tf = false (size (strs));
|
||||
for i = 1:numel (strs)
|
||||
for j = 1:numel (pats)
|
||||
if (ignore_case)
|
||||
hit = strcmpi (strs{i}, pats{j});
|
||||
else
|
||||
hit = strcmp (strs{i}, pats{j});
|
||||
end
|
||||
if (hit)
|
||||
tf(i) = true;
|
||||
break;
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if (ischar (str))
|
||||
tf = tf(1);
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,13 @@
|
||||
function [v, idx] = maxk (x, k, varargin)
|
||||
%MAXK The k largest elements, in descending order (MATLAB R2017b+).
|
||||
%
|
||||
% An owuitools compatibility shim; see contains.m for the policy.
|
||||
% Column-wise for a matrix, like MATLAB and like `sort`. NaNs go last,
|
||||
% which is also what MATLAB does. `k` larger than the data returns
|
||||
% everything there is rather than erroring, again matching MATLAB.
|
||||
|
||||
if (nargin < 2)
|
||||
error ('maxk: not enough input arguments');
|
||||
end
|
||||
[v, idx] = owuitools_topk (x, k, 'descend', 'maxk', varargin{:});
|
||||
end
|
||||
@@ -0,0 +1,10 @@
|
||||
function [v, idx] = mink (x, k, varargin)
|
||||
%MINK The k smallest elements, in ascending order (MATLAB R2017b+).
|
||||
%
|
||||
% An owuitools compatibility shim; see contains.m for the policy.
|
||||
|
||||
if (nargin < 2)
|
||||
error ('mink: not enough input arguments');
|
||||
end
|
||||
[v, idx] = owuitools_topk (x, k, 'ascend', 'mink', varargin{:});
|
||||
end
|
||||
@@ -0,0 +1,25 @@
|
||||
function c = owuitools_text_cell (value, fname, argname)
|
||||
%OWUITOOLS_TEXT_CELL Normalise char / cellstr input for the shims.
|
||||
%
|
||||
% Shared by the text shims in this directory. Returns a cell array of
|
||||
% char rows, and refuses anything else by name so the message reads
|
||||
% like the function the caller actually wrote.
|
||||
%
|
||||
% The name is prefixed rather than generic because this directory sits
|
||||
% ahead of everything on the path: a file called `text_cell.m` here
|
||||
% would shadow anyone's own helper of that name, in their own script,
|
||||
% with no way for them to see why.
|
||||
|
||||
if (ischar (value))
|
||||
if (ndims (value) > 2 || (~isempty (value) && rows (value) > 1))
|
||||
error ('%s: %s must be a single row of characters, not a char matrix', fname, argname);
|
||||
end
|
||||
c = {value};
|
||||
elseif (iscellstr (value))
|
||||
c = value;
|
||||
elseif (iscell (value))
|
||||
error ('%s: %s must be text - a char row or a cell array of char rows', fname, argname);
|
||||
else
|
||||
error ('%s: %s must be text, not %s', fname, argname, class (value));
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,30 @@
|
||||
function [v, idx] = owuitools_topk (x, k, direction, fname, varargin)
|
||||
%OWUITOOLS_TOPK Shared body of the maxk/mink shims.
|
||||
|
||||
if (~isempty (varargin))
|
||||
error ('%s: this shim takes only (x, k) - no dimension or option arguments', fname);
|
||||
end
|
||||
if (~isnumeric (x) && ~islogical (x))
|
||||
error ('%s: x must be numeric', fname);
|
||||
end
|
||||
if (~isnumeric (k) || ~isscalar (k) || k < 0 || k ~= fix (k))
|
||||
error ('%s: k must be a non-negative integer', fname);
|
||||
end
|
||||
|
||||
if (isvector (x) || isempty (x))
|
||||
[sorted, order] = sort (x(:), direction);
|
||||
n = min (k, numel (sorted));
|
||||
v = sorted(1:n);
|
||||
idx = order(1:n);
|
||||
if (size (x, 1) == 1 && ~isempty (x)) % a row in, a row out
|
||||
v = v.';
|
||||
idx = idx.';
|
||||
end
|
||||
return;
|
||||
end
|
||||
|
||||
[sorted, order] = sort (x, 1, direction);
|
||||
n = min (k, size (sorted, 1));
|
||||
v = sorted(1:n, :);
|
||||
idx = order(1:n, :);
|
||||
end
|
||||
@@ -0,0 +1,278 @@
|
||||
function varargout = print (varargin)
|
||||
%PRINT Transparent shim: render PNGs through gnuplot's cairo terminal.
|
||||
%
|
||||
% The model writes ordinary MATLAB - print(f,'plot.png','-dpng') - and must
|
||||
% keep writing ordinary MATLAB, because whatever it writes is what the user
|
||||
% runs. But gnuplot's plain `png` terminal does not anti-alias, and draws
|
||||
% every line as a 1-pixel stroke regardless of LineWidth. On a steep curve
|
||||
% that needs several vertical pixels per column it leaves gaps, so a 4 Hz
|
||||
% sine came out as a field of disconnected dashes rather than a wave.
|
||||
%
|
||||
% `pngcairo` anti-aliases and renders the same LineWidth as a continuous
|
||||
% stroke. Swapping it in here means the picture is right without the code
|
||||
% being wrong: -dpngcairo is Octave-only and would fail in MATLAB, so it
|
||||
% must never appear in anything the model writes.
|
||||
%
|
||||
% The layout goes the same way. Canvas size, type size and where the key
|
||||
% sits are properties of this renderer, not of the analysis, so they are
|
||||
% set here rather than in lines the model would have to remember to write.
|
||||
% Three fixes, each explained at its helper below:
|
||||
%
|
||||
% __owuitools_canvas__ a big canvas WITHOUT print's font scaling
|
||||
% __owuitools_type__ label type the size gnuplot reserves for
|
||||
% __owuitools_legends_below__ the key under the axes, not over the data
|
||||
%
|
||||
% Every one of them is undone before this function returns: the figure is
|
||||
% handed back exactly as the script left it, because the script may draw
|
||||
% into it again and print it a second time.
|
||||
%
|
||||
% This file lives outside the working directory and is put on the path
|
||||
% ahead of Octave's own print, so the user's files are untouched.
|
||||
|
||||
args = varargin;
|
||||
has_device = false;
|
||||
has_size = false;
|
||||
has_resolution = false;
|
||||
wants_png = false;
|
||||
% The layout work below is about pixels, so it covers every picture the
|
||||
% tool can hand back - .png, .jpg, .gif - while the cairo swap is about
|
||||
% one gnuplot terminal and stays with PNG alone.
|
||||
raster_ext = {'.png', '.jpg', '.jpeg', '.gif'};
|
||||
raster_dev = {'-dpng', '-djpg', '-djpeg', '-dgif'};
|
||||
wants_raster = false;
|
||||
|
||||
for k = 1:numel (args)
|
||||
if (ischar (args{k}))
|
||||
a = args{k};
|
||||
if (strncmpi (a, '-S', 2))
|
||||
has_size = true;
|
||||
elseif (strncmpi (a, '-r', 2) && numel (a) > 2)
|
||||
has_resolution = true;
|
||||
elseif (strncmpi (a, '-d', 2) && ! strncmpi (a, '-debug', 6))
|
||||
has_device = true;
|
||||
if (any (strcmpi (a, raster_dev)))
|
||||
wants_raster = true;
|
||||
end
|
||||
if (strcmpi (a, '-dpng'))
|
||||
args{k} = '-dpngcairo';
|
||||
wants_png = true;
|
||||
end
|
||||
elseif (a(1) != '-')
|
||||
[~, ~, ext] = fileparts (a);
|
||||
if (any (strcmpi (ext, raster_ext)))
|
||||
wants_raster = true;
|
||||
wants_png = wants_png || strcmpi (ext, '.png');
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
% print() also infers the device from the file extension, so a bare
|
||||
% print(f,'plot.png') has to be caught too.
|
||||
if (wants_png && ! has_device)
|
||||
args{end+1} = '-dpngcairo';
|
||||
end
|
||||
|
||||
% Paper inches become pixels at the print resolution, which defaults to
|
||||
% 150 dpi: the JPEG terminal took the 1100 pt canvas set below and made a
|
||||
% 2292 px image of it, with 10 pt type marooned in the middle. pngcairo
|
||||
% ignores the setting, so pinning it to 72 costs nothing there and makes
|
||||
% one inch mean one hundred points wherever it is read.
|
||||
if (wants_raster && ! has_size && ! has_resolution)
|
||||
args{end+1} = '-r72';
|
||||
end
|
||||
|
||||
shim_dir = fileparts (mfilename ('fullpath'));
|
||||
rmpath (shim_dir);
|
||||
unwind_protect
|
||||
undo = {};
|
||||
if (wants_raster)
|
||||
fig = __owuitools_figure__ (args);
|
||||
undo = [__owuitools_canvas__(fig, has_size), ...
|
||||
__owuitools_type__(fig), ...
|
||||
__owuitools_legends_below__(fig)];
|
||||
end
|
||||
unwind_protect
|
||||
if (nargout > 0)
|
||||
[varargout{1:nargout}] = print (args{:});
|
||||
else
|
||||
print (args{:});
|
||||
end
|
||||
unwind_protect_cleanup
|
||||
for k = numel (undo):-1:1
|
||||
try
|
||||
set (undo{k}{1}, undo{k}{2}, undo{k}{3});
|
||||
catch
|
||||
end
|
||||
end
|
||||
end_unwind_protect
|
||||
unwind_protect_cleanup
|
||||
addpath (shim_dir, '-begin');
|
||||
end_unwind_protect
|
||||
end
|
||||
|
||||
function fig = __owuitools_figure__ (args)
|
||||
% print(f,...) names its figure; print('plot.png',...) means the current one.
|
||||
fig = [];
|
||||
try
|
||||
for k = 1:numel (args)
|
||||
if (! ischar (args{k}) && isscalar (args{k}) && ishghandle (args{k}))
|
||||
fig = ancestor (args{k}, 'figure');
|
||||
return;
|
||||
end
|
||||
end
|
||||
if (! isempty (get (0, 'currentfigure')))
|
||||
fig = gcf ();
|
||||
end
|
||||
catch
|
||||
fig = [];
|
||||
end
|
||||
end
|
||||
|
||||
function undo = __owuitools_canvas__ (fig, has_size)
|
||||
%__OWUITOOLS_CANVAS__ A bigger picture without doubling the type with it.
|
||||
%
|
||||
% -S is the obvious way to ask for a 1100 px canvas and the wrong one.
|
||||
% print.m reads it as "a six-inch figure blown up to 1100 px" and scales
|
||||
% every font in the figure by
|
||||
%
|
||||
% width / 6 / screenpixelsperinch (72 here, so width/432)
|
||||
%
|
||||
% which at the old -S900,675 was 2.08x: the 10 pt axes font was drawn at
|
||||
% 16.7 pt and the 11 pt title at 18.3 pt on a canvas only 900 px wide.
|
||||
% That is what pushed titles off the right edge, sat axis labels on top
|
||||
% of tick labels, and ran bar-chart category names into each other. A
|
||||
% bigger -S never helped, because the type grew with the canvas.
|
||||
%
|
||||
% Sizing by paper instead takes the other branch of print.m, where the
|
||||
% canvas is paperposition x 72 and the scale factor stays 1 - so the type
|
||||
% keeps the size Octave gave it and a bigger canvas buys real room.
|
||||
%
|
||||
% The shape follows the figure: a script that made a wide figure gets a
|
||||
% wide PNG. Only the width is fixed, and only when the caller did not ask
|
||||
% for a size itself.
|
||||
undo = {};
|
||||
if (has_size || isempty (fig))
|
||||
return;
|
||||
end
|
||||
try
|
||||
width = 1100;
|
||||
pos = get (fig, 'position');
|
||||
aspect = 0.75;
|
||||
if (numel (pos) == 4 && pos(3) > 0 && pos(4) > 0)
|
||||
aspect = min (1.5, max (0.4, pos(4) / pos(3)));
|
||||
end
|
||||
undo = {{fig, 'paperunits', get(fig, 'paperunits')}, ...
|
||||
{fig, 'paperposition', get(fig, 'paperposition')}, ...
|
||||
{fig, 'paperpositionmode', get(fig, 'paperpositionmode')}};
|
||||
set (fig, 'paperunits', 'inches');
|
||||
set (fig, 'paperposition', [0, 0, width/72, round(width*aspect)/72]);
|
||||
set (fig, 'paperpositionmode', 'manual');
|
||||
catch
|
||||
end
|
||||
end
|
||||
|
||||
function undo = __owuitools_type__ (fig)
|
||||
%__OWUITOOLS_TYPE__ Axis labels the size gnuplot reserved room for.
|
||||
%
|
||||
% gnuplot lays a plot out in character units of the TERMINAL font, and
|
||||
% Octave sets that terminal font from the axes font size - 10 pt - while
|
||||
% drawing axis labels at 11, because labelfontsizemultiplier is 1.1. The
|
||||
% gap is small and the result is not: the space reserved below the tick
|
||||
% labels is a 10 pt line, the xlabel drawn into it is an 11 pt line, and
|
||||
% it lands on the tick row. Same story on the left for the ylabel.
|
||||
%
|
||||
% Dropping the label multiplier to 1 makes the two agree exactly, which
|
||||
% is the whole fix - verified against gnuplot directly: same plot, same
|
||||
% key, xlabel at 11 pt overlaps the ticks and at 10 pt clears them.
|
||||
%
|
||||
% The title keeps its 1.1 and its bold weight. It sits in the top margin
|
||||
% with nothing under it to collide with, and it is the one piece of text
|
||||
% a reader should find first.
|
||||
%
|
||||
% With that tie in place the size can be chosen on its merits, and 10 pt
|
||||
% - Octave's default, for a 560 px figure on a screen - is too small on a
|
||||
% 1100 px canvas that a chat window then shows at 700. 12 pt is about
|
||||
% where it reads at that width and still leaves six category names room
|
||||
% side by side. A script that set a font size itself keeps it: that is
|
||||
% what fontsizemode records.
|
||||
undo = {};
|
||||
if (isempty (fig))
|
||||
return;
|
||||
end
|
||||
try
|
||||
ax = findall (fig, 'type', 'axes');
|
||||
for k = 1:numel (ax)
|
||||
if (strcmp (get (ax(k), 'tag'), 'legend'))
|
||||
continue;
|
||||
end
|
||||
undo{end+1} = {ax(k), 'labelfontsizemultiplier', ...
|
||||
get(ax(k), 'labelfontsizemultiplier')};
|
||||
set (ax(k), 'labelfontsizemultiplier', 1);
|
||||
if (strcmp (get (ax(k), 'fontsizemode'), 'auto'))
|
||||
undo{end+1} = {ax(k), 'fontsize', get(ax(k), 'fontsize')};
|
||||
set (ax(k), 'fontsize', 12);
|
||||
end
|
||||
end
|
||||
catch
|
||||
end
|
||||
end
|
||||
|
||||
function undo = __owuitools_legends_below__ (fig)
|
||||
%__OWUITOOLS_LEGENDS_BELOW__ The key under the axes, not over the data.
|
||||
%
|
||||
% A legend inside the axes sits on top of what it is labelling: the key
|
||||
% is opaque here (see patch-legend-opaque.sh, and transparent is worse -
|
||||
% the lines then run through the text), so it hides the curves under it.
|
||||
% 'best' does not help either; this backend does not implement it and
|
||||
% warns, and every legend lands top-right regardless of what was asked.
|
||||
%
|
||||
% Below the axes nothing can collide with the data, because gnuplot
|
||||
% reserves the space for an outside key and shrinks the plot to fit it.
|
||||
%
|
||||
% The frame comes off with the move. gnuplot packs an outside key flush
|
||||
% against the x-axis label - a one-pixel gap, whatever the type size or
|
||||
% the label's own offset - so the box's top edge is drawn through the
|
||||
% descenders of that label and reads as an underline under it. Out on
|
||||
% the white margin, with no plot lines behind the text, the frame was
|
||||
% only decoration; without it the key is a caption under the plot.
|
||||
% (The fill is handled one layer down: patch-legend-opaque.sh keeps an
|
||||
% outside key transparent, because an opaque one erased those same
|
||||
% descenders outright - "the trigger edge" printed as "the triaaer edae".)
|
||||
%
|
||||
% Two legends are left where they are. One a script deliberately put
|
||||
% outside already - 'eastoutside' - because that is a layout decision
|
||||
% rather than the default falling where it falls. And every legend in a
|
||||
% figure that has more than one plot in it: a subplot grid has no margin
|
||||
% under each plot to give away, only the gap before the next one's title,
|
||||
% and a key dropped into that gap lands on both. Inside is where they fit
|
||||
% there, and inside is where the opaque fill still applies.
|
||||
undo = {};
|
||||
if (isempty (fig))
|
||||
return;
|
||||
end
|
||||
try
|
||||
h = findall (fig, 'type', 'axes');
|
||||
tags = get (h, 'tag');
|
||||
if (! iscell (tags))
|
||||
tags = {tags};
|
||||
end
|
||||
if (sum (! ismember (tags, {'legend', 'colorbar'})) != 1)
|
||||
return;
|
||||
end
|
||||
for k = 1:numel (h)
|
||||
if (! strcmp (get (h(k), 'tag'), 'legend'))
|
||||
continue;
|
||||
end
|
||||
loc = get (h(k), 'location');
|
||||
if (! ischar (loc) || ! isempty (strfind (lower (loc), 'outside')))
|
||||
continue;
|
||||
end
|
||||
undo{end+1} = {h(k), 'location', loc};
|
||||
set (h(k), 'location', 'southoutside');
|
||||
undo{end+1} = {h(k), 'box', get(h(k), 'box')};
|
||||
set (h(k), 'box', 'off');
|
||||
end
|
||||
catch
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,35 @@
|
||||
function [out, removed] = rmmissing (x, varargin)
|
||||
%RMMISSING Drop missing values (MATLAB R2016b+).
|
||||
%
|
||||
% An owuitools compatibility shim; see contains.m for the policy.
|
||||
%
|
||||
% Numeric and logical data only, where "missing" means NaN. MATLAB also
|
||||
% accepts tables, categoricals, strings and datetimes, where missing
|
||||
% means something different in each; none of those types exist here, so
|
||||
% this refuses them rather than inventing an answer. For a vector the
|
||||
% missing elements go; for a matrix the whole ROW goes, as in MATLAB.
|
||||
|
||||
if (nargin < 1)
|
||||
error ('rmmissing: not enough input arguments');
|
||||
end
|
||||
if (~isempty (varargin))
|
||||
error ('rmmissing: this shim takes only (x) - no dimension or option arguments');
|
||||
end
|
||||
if (~isnumeric (x) && ~islogical (x))
|
||||
error (['rmmissing: this sandbox supports numeric and logical input only, not %s - ' ...
|
||||
'MATLAB''s table, string and categorical types do not exist here'], class (x));
|
||||
end
|
||||
|
||||
if (isvector (x) || isempty (x))
|
||||
removed = isnan (double (x(:)));
|
||||
out = x(~removed);
|
||||
if (size (x, 1) == 1 && ~isempty (x))
|
||||
out = reshape (out, 1, []);
|
||||
end
|
||||
removed = reshape (removed, size (x));
|
||||
return;
|
||||
end
|
||||
|
||||
removed = any (isnan (double (x)), 2);
|
||||
out = x(~removed, :);
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
function n = strlength (s)
|
||||
%STRLENGTH Number of characters in each element of text (MATLAB R2016b+).
|
||||
%
|
||||
% An owuitools compatibility shim; see contains.m for the policy.
|
||||
% MATLAB returns a double array shaped like its input, and 0 for the
|
||||
% empty string. With char input it counts the characters, which is what
|
||||
% `numel` does here - the difference only shows up for cell input.
|
||||
|
||||
if (nargin < 1)
|
||||
error ('strlength: not enough input arguments');
|
||||
end
|
||||
if (ischar (s))
|
||||
n = numel (s);
|
||||
return;
|
||||
end
|
||||
c = owuitools_text_cell (s, 'strlength', 'str');
|
||||
n = zeros (size (c));
|
||||
for k = 1:numel (c)
|
||||
n(k) = numel (c{k});
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user