Files
Jungfraujoch/tools/battery/render.py
T
leonarski_fandClaude Opus 5 0c91749a41 Battery: let a row accept more than one reference
A handful of open-arm rows disagree with their deposition on a knife edge that no test
available to us settles. They were handled three different ways - silently overridden to
our answer, marked unscored, or left failing - and none of the three says what is true:
either answer is acceptable as long as the program picks one of them.

A manifest row can now list `ref_alternatives`. Each entry replaces the reference fields
it names - a space group, a cell, or both - and the row passes if the answer matches any
of its references, the deposition included. `ref` keeps the deposited values verbatim in
every case. Every alternative must carry `why`: an accepted alternative with no stated
reason raises rather than passing, so the mechanism cannot be used to launder a failure.

The report keeps these rows visible rather than folding them into the passes: a summary
column counting them, their own segment in the verdict bars, and a section naming each
row, what we read, what was deposited and the reason both are accepted.

Five rows use it. Four are symmetry: a tetragonal row where the refinement test is split
and its spread exceeds the effect, and three trigonal rows where we read a higher point
group - one where the evidence favours our answer, one where our own twin-immune test
favours the deposition, one unresolved in either direction. The fifth is a cell: a real
tNCS supercell whose (0,1/2,1/2) sublattice is what was deposited, both being correct
descriptions of the same lattice. The documentation frames all of them as open questions,
not as errors in a deposition, and states the limit: a merohedral twin at exactly one half
and true higher symmetry predict identical intensities, so no test can close them even in
principle.

`test_score.py` covers the new path: both answers accepted, the other hand of an
alternative, a third answer still failing, the cell case, and the missing-justification
schema error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
2026-09-20 18:45:19 +02:00

189 lines
9.1 KiB
Python

"""One document, written twice: Markdown and a self-contained HTML page.
The report is built as a list of blocks (heading, paragraph, bullet list, table) and each writer
turns the same list into its format, so the two can never say different things.
"""
import html
CSS = """
:root { --bg:#fff; --fg:#1d1d1f; --mute:#6e6e73; --line:#d9d9de; --head:#f3f3f5;
--pass:#1a7f37; --fail:#c62828; --warn:#9a6700; --passbg:#e8f5ec; --failbg:#fdecea;
--series1:#2a78d6; --good:#0ca30c; --critical:#d03b3b; --warning:#fab219;
--neutral:#898781; --grid:#e1e0d9; --axis:#c3c2b7; }
@media (prefers-color-scheme: dark) {
:root { --bg:#161618; --fg:#e8e8ea; --mute:#9a9aa0; --line:#3a3a3e; --head:#232326;
--pass:#5cc97a; --fail:#ff7b72; --warn:#e3b341; --passbg:#16301f; --failbg:#3a1a1a;
--series1:#3987e5; --grid:#2c2c2a; --axis:#383835; }
}
figure { margin:12px 0 20px; max-width:900px; } figcaption { color:var(--mute); font-size:13px; }
svg.plot { width:100%; height:auto; display:block; }
svg.plot text { fill:var(--mute); font:11px system-ui, sans-serif; }
svg.plot .grid { stroke:var(--grid); stroke-width:1; } svg.plot .ref { stroke:var(--axis); stroke-width:2; }
svg.plot circle:hover, svg.plot rect:hover { stroke:var(--fg); stroke-width:2; }
body { background:var(--bg); color:var(--fg); margin:0 auto; max-width:1400px; padding:16px;
font:14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; }
h1 { font-size:22px; margin:8px 0 4px; } h2 { font-size:18px; margin:28px 0 8px; }
h3 { font-size:15px; margin:20px 0 6px; }
p, li { max-width:900px; } .mute { color:var(--mute); }
.wrap { overflow-x:auto; margin:8px 0 16px; }
table { border-collapse:collapse; font-variant-numeric:tabular-nums; }
th, td { border-bottom:1px solid var(--line); padding:4px 8px; text-align:left; white-space:nowrap; }
th { background:var(--head); position:sticky; top:0; }
td.num, th.num { text-align:right; }
td.pass { color:var(--pass); font-weight:600; } td.fail { color:var(--fail); font-weight:600; }
td.warn { color:var(--warn); font-weight:600; }
tr.fail td { background:var(--failbg); }
code { font:12px ui-monospace, Menlo, monospace; }
"""
class Doc:
def __init__(self, title):
self.title = title
self.blocks = []
def h(self, level, text):
self.blocks.append(("h", level, text))
def p(self, text):
self.blocks.append(("p", text))
def ul(self, items):
self.blocks.append(("ul", list(items)))
def svg(self, markup, caption):
"""An inline SVG plot (HTML only; the Markdown names it and points at the HTML)."""
self.blocks.append(("svg", markup, caption))
def table(self, head, rows, num=(), row_class=None, cell_class=None):
"""head: column titles; rows: lists of str; num: indices of right-aligned columns;
row_class(row) and cell_class(i, value) give optional CSS classes (HTML only)."""
self.blocks.append(("table", head, rows, set(num), row_class, cell_class))
def markdown(self):
out = [f"# {self.title}", ""]
for b in self.blocks:
if b[0] == "h":
out += ["#" * b[1] + " " + b[2], ""]
elif b[0] == "p":
out += [b[1], ""]
elif b[0] == "ul":
out += ["- " + x for x in b[1]] + [""]
elif b[0] == "svg":
out += [f"_Plot in report.html: {b[2]}_", ""]
else:
_, head, rows, num, _, _ = b
if not rows:
out += ["(none)", ""]
continue
out.append("| " + " | ".join(head) + " |")
out.append("|" + "|".join("--:" if i in num else ":--" for i in range(len(head))) + "|")
for r in rows:
out.append("| " + " | ".join(str(x).replace("|", "/") for x in r) + " |")
out.append("")
return "\n".join(out)
def html(self):
e = html.escape
out = ["<!DOCTYPE html>", '<html lang="en"><head><meta charset="utf-8">',
'<meta name="viewport" content="width=device-width, initial-scale=1">',
f"<title>{e(self.title)}</title><style>{CSS}</style></head><body>",
f"<h1>{e(self.title)}</h1>"]
for b in self.blocks:
if b[0] == "h":
out.append(f"<h{b[1]}>{e(b[2])}</h{b[1]}>")
elif b[0] == "p":
out.append(f"<p>{e(b[1])}</p>")
elif b[0] == "ul":
out.append("<ul>" + "".join(f"<li>{e(x)}</li>" for x in b[1]) + "</ul>")
elif b[0] == "svg":
out.append(f"<figure>{b[1]}<figcaption>{e(b[2])}</figcaption></figure>")
else:
_, head, rows, num, row_class, cell_class = b
if not rows:
out.append('<p class="mute">(none)</p>')
continue
t = ['<div class="wrap"><table><thead><tr>']
t += [f'<th class="num">{e(h)}</th>' if i in num else f"<th>{e(h)}</th>"
for i, h in enumerate(head)]
t.append("</tr></thead><tbody>")
for r in rows:
rc = row_class(r) if row_class else ""
t.append(f'<tr class="{rc}">' if rc else "<tr>")
for i, x in enumerate(r):
cls = ["num"] if i in num else []
if cell_class and cell_class(i, x):
cls.append(cell_class(i, x))
c = f' class="{" ".join(cls)}"' if cls else ""
t.append(f"<td{c}>{e(str(x))}</td>")
t.append("</tr>")
t.append("</tbody></table></div>")
out.append("".join(t))
out.append("</body></html>")
return "\n".join(out)
# ------------------------------------------------------------------ plots (inline SVG, no libraries)
STATUS = {"pass": "var(--good)", "accepted_alt": "var(--series1)", "fail": "var(--critical)",
"unscored": "var(--neutral)", "not_run": "var(--warning)"}
def verdict_bars(rows):
"""One horizontal bar per arm, split pass / accepted alt / fail / unscored / not run.
rows: [(arm, {status: count})], where a status is a verdict, except that a pass carried by an
accepted alternative reference is counted on its own."""
e = html.escape
w, left, bar, gap = 900, 90, 22, 14
h = 30 + len(rows) * (bar + gap)
total = max(sum(c.values()) for _, c in rows) or 1
out = [f'<svg class="plot" viewBox="0 0 {w} {h}" role="img" aria-label="verdicts per arm">']
x = left
for v, col in STATUS.items(): # legend, above the bars
out.append(f'<rect x="{x}" y="4" width="10" height="10" rx="2" fill="{col}"/>'
f'<text x="{x + 14}" y="13">{v.replace("_", " ")}</text>')
x += 100
for i, (arm, cnt) in enumerate(rows):
y = 26 + i * (bar + gap)
out.append(f'<text x="0" y="{y + bar / 2 + 4}">{e(arm)}</text>')
x = left
for v, col in STATUS.items():
n = cnt.get(v, 0)
if not n:
continue
bw = (w - left - 10) * n / total
out.append(f'<rect x="{x:.1f}" y="{y}" width="{max(bw - 2, 1):.1f}" height="{bar}" rx="4" '
f'fill="{col}"><title>{e(arm)}: {n} {v.replace("_", " ")}</title></rect>')
if bw > 28:
out.append(f'<text x="{x + 6:.1f}" y="{y + bar / 2 + 4}" style="fill:#fff">{n}</text>')
x += bw
out.append("</svg>")
return "".join(out)
def ratio_dots(points, what):
"""Sets sorted by a ratio (ours / reference), on a log axis clipped to 0.5..2, with the line
at 1. points: [(set, ratio, verdict, tooltip)]. Failed sets are drawn in the fail colour."""
e = html.escape
import math
w, h, left, top, bottom = 900, 240, 44, 22, 18
lo, hi = math.log(0.5), math.log(2.0)
y = lambda r: top + (hi - math.log(min(max(r, 0.5), 2.0))) / (hi - lo) * (h - top - bottom)
pts = sorted(points, key=lambda p: p[1])
step = (w - left - 10) / max(len(pts), 1)
out = [f'<svg class="plot" viewBox="0 0 {w} {h}" role="img" aria-label="{e(what)}">']
for t in (0.5, 0.75, 1.0, 1.5, 2.0):
out.append(f'<line class="{"ref" if t == 1.0 else "grid"}" x1="{left}" x2="{w}" '
f'y1="{y(t):.1f}" y2="{y(t):.1f}"/><text x="0" y="{y(t) + 4:.1f}">{t:g}</text>')
out.append(f'<circle cx="{left + 6}" cy="10" r="4" fill="var(--series1)"/>'
f'<text x="{left + 14}" y="14">pass or unscored</text>'
f'<circle cx="{left + 136}" cy="10" r="4" fill="var(--critical)"/>'
f'<text x="{left + 144}" y="14">fail</text>'
f'<text x="{left + 190}" y="14">(clipped to 0.5-2; hover a point for the set)</text>')
for i, (name, r, verdict, tip) in enumerate(pts):
col = "var(--critical)" if verdict == "fail" else "var(--series1)"
out.append(f'<circle cx="{left + step * (i + 0.5):.1f}" cy="{y(r):.1f}" r="4" fill="{col}">'
f'<title>{e(name)}: {r:.3f} - {e(tip)}</title></circle>')
out.append("</svg>")
return "".join(out)