8e6ee31596
ci / test (push) Successful in 6m18s
History-free snapshot of the private development repository at cbe45c5 (2026-09-09). Code, docs and public catalogue only; no plant or job knowledge layers, no client or job names (tools/scrub.py clean). The intro deck is left out of this snapshot because its slide images could not be checked for job drawings.
1093 lines
48 KiB
Python
1093 lines
48 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""Job-level reconciliation: does the issued set of tickets cover the job?
|
||
|
||
WHY THIS MODULE EXISTS.
|
||
|
||
Everything else in this tool judges ONE ticket at a time. A per-ticket check
|
||
cannot see a missing ticket, because the missing ticket is not there to be
|
||
checked -- and cannot see a ticket issued for a piece that no longer exists,
|
||
because it never looks outside the ticket it was handed. The consultancy's
|
||
real exposure is that the SET is wrong. This module is the only thing here
|
||
that asks a question about the set.
|
||
|
||
THE UNIT OF THE RECONCILIATION IS THE CONTROL NUMBER.
|
||
|
||
A control number is one physical piece: one crew, one bed, one day. A mark is
|
||
a drawing, and several identical pieces share it. So:
|
||
|
||
* the model side of the reconciliation is every control number carried by a
|
||
model element -- NOT every assembly. An assembly is a piece somebody has
|
||
already started drawing; a piece that nobody has assembled yet is still a
|
||
piece on the floor, and it is exactly the piece a ticket is most likely to
|
||
be missing for. Reconciling against assemblies alone reports a job clean
|
||
while most of it has never been looked at (measured on a real job: 755
|
||
assemblies, 2480 control-numbered elements).
|
||
|
||
* the ticket side is every control number PRINTED on a ticket, expanded --
|
||
a title-block slot may hold one number, a comma list, or a RANGE
|
||
('50120-50123' is four pieces). Reading a range as one opaque token
|
||
under-counts the ticket by three pieces and invents three orphans.
|
||
|
||
WHAT THIS MODULE MUST NEVER DO.
|
||
|
||
* Invent an exclusion. Nothing is filtered out for looking out of scope.
|
||
A family literally named '... (DO NOT USE)' is still reported, because
|
||
"this looked wrong to the tool so the tool dropped it" is the single
|
||
failure this project has already been bitten by -- one unauthorised
|
||
exclusion hid forty tickets and every check that ran came back clean.
|
||
|
||
A DECLARED scope is the one exception, and it is not an exception to the
|
||
rule above -- it is the rule enforced. `reconcile(scope=...)` takes the
|
||
scope the JOB file declares (`vibe.scope`), sets aside the pieces and
|
||
ticket sheets outside it, and reports that in `out_of_scope`, which
|
||
`format_report` prints as its own section with the counts named by kind.
|
||
The tool never decides this for itself: no scope declared means every
|
||
piece is reconciled, and the same section says so. What is set aside is
|
||
stated in the report; what is dropped silently is the failure.
|
||
|
||
* Report a clean set for a part of the job it could not see. Every result
|
||
carries `blind_spots`: the questions the inputs could not answer. A
|
||
reconciliation that cannot see part of the job says WHICH part.
|
||
|
||
* Count without naming. '3 pieces unaccounted for' sends somebody hunting.
|
||
Every finding here carries the control numbers and marks individually.
|
||
|
||
DATA CONTRACT (plain dicts -- no Revit anywhere in this file).
|
||
|
||
piece {'control', 'mark', 'design', 'id', 'category', 'family',
|
||
'in_assembly', 'ticket_created', 'ticket_detailed'}
|
||
`control` may be None or '' -- that is a finding, not a reason to
|
||
drop the piece.
|
||
|
||
sheet {'id', 'number', 'name', 'design', 'controls' (list of raw
|
||
title-block slot strings, empties included), 'total_required',
|
||
'sheet_count'}
|
||
|
||
The Revit side that fills these in is `vibe.jobreconscan`, the same split
|
||
`ticketpage.py` / `ticketchecks.py` uses.
|
||
|
||
THE REPORT FOLDS. `render()` returns `vibe.foldreport.Section`s: the verdict
|
||
in numbers as a headline that is never folded, every finding that has
|
||
anything in it as a section that opens ITSELF, and everything routine
|
||
collapsed to a header that still says what it holds. `format_report()` is the
|
||
same sections as plain text with nothing folded and nothing capped -- the
|
||
copy the button writes to disk, so a fold that will not open costs a file
|
||
open and never a sentence. The reasoning, section by section, is the comment
|
||
block above `render`.
|
||
|
||
IronPython 2.7: no f-strings, no pathlib, no typing, no dataclasses.
|
||
"""
|
||
import re
|
||
|
||
from vibe.compat import string_types
|
||
from vibe import foldreport as fold
|
||
from vibe import scope as scopelib
|
||
|
||
# A sheet number that reads as a page of a ticket set: '1 OF 2', '2OF 2',
|
||
# '1 of 1'. Tolerant of the missing space, which real sheets have.
|
||
PAGE_RE = re.compile(r'^\s*(\d+)\s*OF\s*(\d+)\s*$', re.IGNORECASE)
|
||
|
||
# One control number as printed. Kept deliberately loose -- a plant that
|
||
# letters its control numbers is not this plant's problem to reject.
|
||
_PLAIN_RE = re.compile(r'^[A-Za-z0-9_.]+$')
|
||
_RANGE_RE = re.compile(r'^([A-Za-z]*)(\d+)\s*-\s*([A-Za-z]*)(\d+)$')
|
||
|
||
# Dashes a detailer's keyboard, autocorrect or a pasted PDF can produce, all
|
||
# meaning 'to'. They are normalised to a plain hyphen before the range is
|
||
# matched: an en dash in one slot must not turn four pieces into one
|
||
# unreadable token.
|
||
_DASHES = (u'‐', u'‑', u'‒', u'–', u'—', u'―',
|
||
u'−')
|
||
|
||
# How far a printed range may run before it is treated as unreadable rather
|
||
# than expanded. A title-block slot holding '50120-50123' is four pieces; a
|
||
# slot holding '1-99999' is a typo, and materialising 99999 fake pieces would
|
||
# bury the real finding. 500 is far above any real casting run and far below
|
||
# anything that could be a mis-keyed year or dimension.
|
||
MAX_RANGE = 500
|
||
|
||
SEPARATORS = re.compile(r'[,;/]| and ', re.IGNORECASE)
|
||
|
||
|
||
def expand_control_field(value):
|
||
"""One title-block control slot -> (controls, unreadable tokens).
|
||
|
||
Handles the three forms real tickets use, and says so when it meets a
|
||
fourth rather than guessing:
|
||
|
||
'20037' -> ['20037']
|
||
'60011, 60018' -> ['60011', '60018']
|
||
'50120-50123' -> ['50120', '50121', '50122', '50123']
|
||
'PL 1/4" X 28" X 4'-0"' -> [], ["PL 1/4\\" X 28\\" X 4'-0\\""]
|
||
|
||
The last case is real: on this plant's non-ticket sheets the same
|
||
parameter slots carry a material list. A token that is not a control
|
||
number is RETURNED, never dropped -- a slot the tool cannot read is a
|
||
slot whose pieces it cannot vouch for.
|
||
"""
|
||
if value is None:
|
||
return [], []
|
||
text = value.strip() if isinstance(value, string_types) else str(value)
|
||
if not text:
|
||
return [], []
|
||
for dash in _DASHES:
|
||
if dash in text:
|
||
text = text.replace(dash, u'-')
|
||
controls, unreadable = [], []
|
||
for part in SEPARATORS.split(text):
|
||
part = part.strip()
|
||
if not part:
|
||
continue
|
||
m = _RANGE_RE.match(part)
|
||
if m:
|
||
prefix_a, lo, prefix_b, hi = m.group(1), m.group(2), m.group(3), m.group(4)
|
||
width = len(lo)
|
||
lo_n, hi_n = int(lo), int(hi)
|
||
if prefix_a.upper() != prefix_b.upper() or hi_n < lo_n:
|
||
unreadable.append(part)
|
||
continue
|
||
if hi_n - lo_n > MAX_RANGE:
|
||
unreadable.append(part)
|
||
continue
|
||
for n in range(lo_n, hi_n + 1):
|
||
controls.append(prefix_a + str(n).zfill(width))
|
||
continue
|
||
if _PLAIN_RE.match(part):
|
||
controls.append(part)
|
||
else:
|
||
unreadable.append(part)
|
||
return controls, unreadable
|
||
|
||
|
||
def sheet_controls(sheet):
|
||
"""Every control number a sheet prints, plus what it could not read."""
|
||
controls, unreadable = [], []
|
||
for slot in (sheet.get('controls') or []):
|
||
got, bad = expand_control_field(slot)
|
||
controls.extend(got)
|
||
unreadable.extend(bad)
|
||
return controls, unreadable
|
||
|
||
|
||
def page_of(sheet):
|
||
"""(page, of) from a sheet number, or (None, None)."""
|
||
m = PAGE_RE.match(sheet.get('number') or '')
|
||
if not m:
|
||
return None, None
|
||
return int(m.group(1)), int(m.group(2))
|
||
|
||
|
||
def classify_sheet(sheet, known_marks):
|
||
"""Is this sheet a page of a shop ticket? ('ticket'|'other', why)
|
||
|
||
TWO SIGNALS, AND THE UNION OF THEM -- read this before narrowing it.
|
||
|
||
A ticket page is recognised by (a) its NAME being a piece mark the model
|
||
carries, or (b) its sheet number reading as a page ('1 OF 2') AND its
|
||
title block carrying a design number.
|
||
|
||
Requiring BOTH is the trap. A multi-page ticket's later sheets are
|
||
stripped of the title block -- no length, no weight, no f'c, no design
|
||
number -- so an and-gate silently drops them, and every finding that
|
||
depends on a page-2 disappears with them. Measured here: 28 real ticket
|
||
pages carry the mark but no design number.
|
||
|
||
Requiring only (b) is the other half of the same trap: it drops the
|
||
stripped pages too. Only the union sees a whole ticket.
|
||
|
||
A sheet reaching (a) alone or (b) alone is still a ticket, and the reason
|
||
string records which signal fired so a reader can see how sure the tool is.
|
||
"""
|
||
name = (sheet.get('name') or '').strip()
|
||
by_name = bool(name) and name in (known_marks or ())
|
||
page, _total = page_of(sheet)
|
||
by_block = page is not None and bool(sheet.get('design'))
|
||
if by_name and by_block:
|
||
return 'ticket', 'named after a piece mark, with a page number and a design number'
|
||
if by_name:
|
||
return 'ticket', 'named after a piece mark (title block stripped or absent)'
|
||
if by_block:
|
||
return 'ticket', 'page number and design number, but its name is not a piece mark'
|
||
return 'other', 'neither a piece mark for a name nor a page-numbered title block'
|
||
|
||
|
||
def page_set_status(pages):
|
||
"""Job-level version of the per-ticket page-set check.
|
||
|
||
`pages` -- the (page, of) pairs of every sheet found for one mark.
|
||
Returns (status, message) where status is 'ok', 'unjudged' or 'broken'.
|
||
|
||
'unjudged' is not 'ok'. A set with no page markers at all has not been
|
||
checked; saying OK there is the exact bug this whole module exists to
|
||
catch.
|
||
"""
|
||
marked = [p for p in pages if p[0] is not None]
|
||
if not marked:
|
||
return 'unjudged', ('no sheet carries a page marker -- the ticket set '
|
||
'could not be judged')
|
||
totals = sorted(set(p[1] for p in marked))
|
||
if len(totals) > 1:
|
||
return 'broken', ('pages disagree on how many there are: {0}'
|
||
.format(', '.join(str(t) for t in totals)))
|
||
total = totals[0]
|
||
have = sorted(p[0] for p in marked)
|
||
missing = [n for n in range(1, total + 1) if n not in have]
|
||
dupes = sorted(set(n for n in have if have.count(n) > 1))
|
||
if missing and dupes:
|
||
return 'broken', ('page(s) {0} missing and page(s) {1} duplicated out '
|
||
'of {2}'.format(_join(missing), _join(dupes), total))
|
||
if missing:
|
||
return 'broken', ('page(s) {0} missing out of {1}'
|
||
.format(_join(missing), total))
|
||
if dupes:
|
||
return 'broken', ('page(s) {0} appear more than once'.format(_join(dupes)))
|
||
if len(marked) < len(pages):
|
||
return 'broken', ('{0} of {1} sheets carry no page marker'
|
||
.format(len(pages) - len(marked), len(pages)))
|
||
return 'ok', '{0}-page ticket'.format(total)
|
||
|
||
|
||
def _join(values):
|
||
return ', '.join(str(v) for v in values)
|
||
|
||
|
||
def _as_int(value):
|
||
if value is None:
|
||
return None
|
||
try:
|
||
return int(str(value).replace(',', '').strip())
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
# --------------------------------------------------------------- the recon
|
||
|
||
def reconcile(pieces, sheets, sort_key=None, scope=None):
|
||
"""Reconcile the model's pieces against the issued ticket set.
|
||
|
||
pieces -- every control-numbered model element (see the data contract).
|
||
sheets -- every sheet in the document, ticket or not. Handing this
|
||
function only the sheets a caller already believes are tickets
|
||
would move the classification -- and its blind spots -- out of
|
||
the reconciliation, where nobody can see it.
|
||
scope -- a `vibe.scope.Scope`. Pieces and sheets outside the DECLARED
|
||
scope are set aside before anything is judged and reported
|
||
under `out_of_scope`. None, or an undeclared scope, reconciles
|
||
everything -- and still fills `out_of_scope` with the line
|
||
saying nobody declared one.
|
||
|
||
Returns a dict; every list in it names its members individually.
|
||
"""
|
||
sort_key = sort_key or (lambda s: s)
|
||
if scope is None:
|
||
scope = scopelib.Scope()
|
||
|
||
# ---- the declared scope, before anything is judged ------------------
|
||
#
|
||
# A sheet is named for its mark, so the same predicate reaches both. A
|
||
# sheet with no readable name falls back to its design number, and one
|
||
# with neither stays IN -- 'could not tell' never removes work.
|
||
all_pieces, all_sheets = pieces, sheets
|
||
pieces, out_pieces = scope.split(pieces or [],
|
||
key=lambda p: (p.get('mark') or ''))
|
||
sheets, out_sheets = scope.split(
|
||
sheets or [],
|
||
key=lambda s: scopelib.as_mark(s.get('name') or s.get('design') or ''))
|
||
out_of_scope = {
|
||
'declared': scope.is_declared,
|
||
'label': scope.label(),
|
||
'declaration': scope.declaration_lines(),
|
||
'pieces': scope.tally([p.get('mark') for p in out_pieces]),
|
||
'sheets': scope.tally([(s.get('name') or s.get('design'))
|
||
for s in out_sheets]),
|
||
'piece_count': len(out_pieces),
|
||
'sheet_count': len(out_sheets),
|
||
'piece_line': scope.line(
|
||
[p.get('mark') for p in out_pieces],
|
||
total=len(all_pieces or []), unit='model element',
|
||
unreadable_names=[p.get('mark') for p in (all_pieces or [])
|
||
if scope.is_unreadable(p.get('mark') or '')]),
|
||
'sheet_line': scope.line(
|
||
[(s.get('name') or s.get('design')) for s in out_sheets],
|
||
total=len(all_sheets or []), unit='sheet'),
|
||
}
|
||
known_marks = set()
|
||
for p in pieces:
|
||
mark = (p.get('mark') or '').strip()
|
||
if mark:
|
||
known_marks.add(mark)
|
||
|
||
# ---- model side ----------------------------------------------------
|
||
by_control = {}
|
||
no_control = []
|
||
for p in pieces:
|
||
control = (p.get('control') or '').strip()
|
||
if not control:
|
||
no_control.append(p)
|
||
continue
|
||
by_control.setdefault(control, []).append(p)
|
||
|
||
marks_of_control = {}
|
||
for control, group in by_control.items():
|
||
marks_of_control[control] = sorted(set(
|
||
(g.get('mark') or '?').strip() for g in group))
|
||
|
||
model_marks = {}
|
||
for control, group in by_control.items():
|
||
for mark in marks_of_control[control]:
|
||
model_marks.setdefault(mark, set()).add(control)
|
||
|
||
# ---- ticket side ---------------------------------------------------
|
||
ticket_pages, other_sheets = [], []
|
||
for s in sheets:
|
||
kind, why = classify_sheet(s, known_marks)
|
||
(ticket_pages if kind == 'ticket' else other_sheets).append((s, why))
|
||
|
||
issued = {} # control -> set of ticket names printing it
|
||
unreadable_slots = []
|
||
pages_by_mark = {}
|
||
for s, _why in ticket_pages:
|
||
name = (s.get('name') or '').strip() or '(unnamed sheet)'
|
||
pages_by_mark.setdefault(name, []).append(s)
|
||
controls, bad = sheet_controls(s)
|
||
for token in bad:
|
||
unreadable_slots.append({
|
||
'sheet': name, 'page': s.get('number'), 'id': s.get('id'),
|
||
'token': token,
|
||
})
|
||
for c in controls:
|
||
issued.setdefault(c, set()).add(name)
|
||
|
||
# A ticket page carrying no control number at all cannot vouch for
|
||
# anything -- and on a stripped page 2 that is CORRECT and expected, so
|
||
# it is reported as a fact about the set, not as a failure.
|
||
silent_pages = []
|
||
for s, _why in ticket_pages:
|
||
controls, _bad = sheet_controls(s)
|
||
if not controls:
|
||
silent_pages.append({'sheet': (s.get('name') or '').strip(),
|
||
'page': s.get('number'), 'id': s.get('id')})
|
||
|
||
model_controls = set(by_control)
|
||
issued_controls = set(issued)
|
||
|
||
issued_by_mark = {}
|
||
for control, names in issued.items():
|
||
for name in names:
|
||
issued_by_mark.setdefault(name, set()).add(control)
|
||
|
||
# ---- 1. every piece accounted for, every ticket for a real piece ----
|
||
#
|
||
# Two very different failures live in this one set, and merging them
|
||
# buries the worse one. A piece whose mark has NO ticket is work that
|
||
# visibly has not been drawn. A piece whose mark HAS a ticket that does
|
||
# not print its control number is work everyone believes is done: the
|
||
# ticket exists, the per-ticket checks pass on it, and the piece is still
|
||
# unaccounted for. `mark_has_ticket` separates them; the report leads
|
||
# with the second.
|
||
pieces_without_ticket = []
|
||
for control in sorted(model_controls - issued_controls, key=sort_key):
|
||
group = by_control[control]
|
||
first = group[0]
|
||
pieces_without_ticket.append({
|
||
'control': control,
|
||
'marks': marks_of_control[control],
|
||
'design': first.get('design'),
|
||
'ids': [g.get('id') for g in group],
|
||
'ticket_created': first.get('ticket_created'),
|
||
'ticket_detailed': first.get('ticket_detailed'),
|
||
'in_assembly': any(g.get('in_assembly') for g in group),
|
||
'mark_has_ticket': any(m in issued_by_mark
|
||
for m in marks_of_control[control]),
|
||
})
|
||
|
||
controls_without_piece = []
|
||
for control in sorted(issued_controls - model_controls, key=sort_key):
|
||
controls_without_piece.append({
|
||
'control': control,
|
||
'sheets': sorted(issued[control]),
|
||
})
|
||
|
||
# ---- 2. counts reconcile, per mark ---------------------------------
|
||
mark_count_mismatch = []
|
||
for mark in sorted(set(issued_by_mark) | set(model_marks)):
|
||
printed = issued_by_mark.get(mark, set())
|
||
modelled = model_marks.get(mark, set())
|
||
if not printed:
|
||
continue # no ticket at all -- reported below
|
||
# Compared as SETS, not as counts. Two numbers that happen to be
|
||
# equal while naming different pieces is the quietest way for a
|
||
# count check to pass on a wrong ticket -- a ticket that prints one
|
||
# piece of this mark and one piece of another reconciles perfectly
|
||
# on arithmetic and is wrong on both pieces.
|
||
if printed == modelled:
|
||
continue
|
||
mark_count_mismatch.append({
|
||
'mark': mark,
|
||
'issued': len(printed),
|
||
'modelled': len(modelled),
|
||
'issued_only': sorted(printed - modelled, key=sort_key),
|
||
'modelled_only': sorted(modelled - printed, key=sort_key),
|
||
})
|
||
|
||
marks_without_ticket = []
|
||
for mark in sorted(set(model_marks) - set(issued_by_mark)):
|
||
controls = sorted(model_marks[mark], key=sort_key)
|
||
marks_without_ticket.append({
|
||
'mark': mark,
|
||
'pieces': len(controls),
|
||
'controls': controls,
|
||
'design': _first_design(by_control, controls),
|
||
})
|
||
|
||
# ---- 3. what the ticket itself claims ------------------------------
|
||
quantity_disagreements = []
|
||
for s, _why in ticket_pages:
|
||
stated = _as_int(s.get('total_required'))
|
||
if stated is None:
|
||
continue
|
||
controls, _bad = sheet_controls(s)
|
||
listed = len(set(controls))
|
||
if listed and stated != listed:
|
||
quantity_disagreements.append({
|
||
'sheet': (s.get('name') or '').strip(),
|
||
'page': s.get('number'), 'id': s.get('id'),
|
||
'stated': stated, 'listed': listed,
|
||
})
|
||
|
||
design_disagreements = []
|
||
for s, _why in ticket_pages:
|
||
printed = (s.get('design') or '').strip()
|
||
if not printed:
|
||
continue
|
||
controls, _bad = sheet_controls(s)
|
||
designs = set()
|
||
for c in controls:
|
||
for g in by_control.get(c, []):
|
||
d = (g.get('design') or '').strip()
|
||
if d:
|
||
designs.add(d)
|
||
if designs and printed not in designs:
|
||
design_disagreements.append({
|
||
'sheet': (s.get('name') or '').strip(),
|
||
'page': s.get('number'), 'id': s.get('id'),
|
||
'printed': printed, 'pieces_say': sorted(designs),
|
||
})
|
||
|
||
page_set_problems = []
|
||
for mark in sorted(pages_by_mark):
|
||
pages = [page_of(s) for s in pages_by_mark[mark]]
|
||
status, message = page_set_status(pages)
|
||
if status != 'ok':
|
||
page_set_problems.append({
|
||
'mark': mark, 'status': status, 'message': message,
|
||
'numbers': [s.get('number') for s in pages_by_mark[mark]],
|
||
})
|
||
|
||
stated_page_counts = []
|
||
for mark in sorted(pages_by_mark):
|
||
group = pages_by_mark[mark]
|
||
stated = None
|
||
for s in group:
|
||
stated = _as_int(s.get('sheet_count'))
|
||
if stated is not None:
|
||
break
|
||
if stated is not None and stated != len(group):
|
||
stated_page_counts.append({
|
||
'mark': mark, 'stated': stated, 'found': len(group),
|
||
})
|
||
|
||
# ---- 4. the model's own claim about its tickets ---------------------
|
||
state_contradictions = []
|
||
ticketed_marks = set(issued_by_mark)
|
||
for control in sorted(model_controls, key=sort_key):
|
||
group = by_control[control]
|
||
first = group[0]
|
||
created = _yes(first.get('ticket_created'))
|
||
covered = control in issued_controls
|
||
has_named_sheet = any(m in ticketed_marks
|
||
for m in marks_of_control[control])
|
||
if created and not covered and has_named_sheet:
|
||
# The worst shape in the whole reconciliation: the ticket exists,
|
||
# the model says it was created, every per-ticket check passes on
|
||
# it -- and this piece is not printed on it. Nothing that reads
|
||
# one ticket at a time can see this.
|
||
state_contradictions.append({
|
||
'control': control, 'marks': marks_of_control[control],
|
||
'design': first.get('design'),
|
||
'problem': ('the model says its ticket was created, and the '
|
||
'ticket for its mark does not print this control '
|
||
'number'),
|
||
})
|
||
elif created and not covered:
|
||
state_contradictions.append({
|
||
'control': control, 'marks': marks_of_control[control],
|
||
'design': first.get('design'),
|
||
'problem': 'the model says its ticket was created, and there is no ticket',
|
||
})
|
||
elif covered and created is False:
|
||
state_contradictions.append({
|
||
'control': control, 'marks': marks_of_control[control],
|
||
'design': first.get('design'),
|
||
'problem': 'a ticket prints this piece, and the model says no ticket was created',
|
||
})
|
||
|
||
# ---- 5. one control number, several elements ------------------------
|
||
shared_controls = []
|
||
for control in sorted(model_controls, key=sort_key):
|
||
group = by_control[control]
|
||
if len(group) < 2:
|
||
continue
|
||
shared_controls.append({
|
||
'control': control,
|
||
'marks': marks_of_control[control],
|
||
'count': len(group),
|
||
'ids': [g.get('id') for g in group],
|
||
'families': sorted(set(g.get('family') or '?' for g in group)),
|
||
})
|
||
|
||
# ---- coverage, by design -------------------------------------------
|
||
coverage = {}
|
||
for control, group in by_control.items():
|
||
design = (group[0].get('design') or '(no design number)').strip() \
|
||
or '(no design number)'
|
||
row = coverage.setdefault(design, {'pieces': 0, 'covered': 0,
|
||
'uncovered': []})
|
||
row['pieces'] += 1
|
||
if control in issued_controls:
|
||
row['covered'] += 1
|
||
else:
|
||
row['uncovered'].append(control)
|
||
for row in coverage.values():
|
||
row['uncovered'].sort(key=sort_key)
|
||
|
||
# ---- what could not be judged ---------------------------------------
|
||
blind = []
|
||
if no_control:
|
||
blind.append('{0} model element(s) carry no control number and could '
|
||
'not be reconciled at all -- ids {1}'
|
||
.format(len(no_control),
|
||
_join([p.get('id') for p in no_control][:20])))
|
||
if unreadable_slots:
|
||
blind.append('{0} title-block control slot(s) hold text this tool '
|
||
'cannot read as a control number -- the pieces behind '
|
||
'them are unaccounted for either way'
|
||
.format(len(unreadable_slots)))
|
||
weak = [s for s, why in ticket_pages if 'stripped' in why or 'not a piece mark' in why]
|
||
if weak:
|
||
blind.append('{0} ticket page(s) were recognised on one signal only '
|
||
'(name, or title block, not both)'.format(len(weak)))
|
||
unjudged_sets = [p for p in page_set_problems if p['status'] == 'unjudged']
|
||
if unjudged_sets:
|
||
blind.append('{0} ticket set(s) carry no page markers, so whether the '
|
||
'set is complete was not judged'.format(len(unjudged_sets)))
|
||
|
||
return {
|
||
'totals': {
|
||
'elements': len(pieces),
|
||
'pieces': len(model_controls),
|
||
'pieces_covered': len(model_controls & issued_controls),
|
||
'pieces_uncovered': len(model_controls - issued_controls),
|
||
'controls_printed': len(issued_controls),
|
||
'controls_without_piece': len(issued_controls - model_controls),
|
||
'marks': len(model_marks),
|
||
'marks_with_ticket': len(set(model_marks) & set(issued_by_mark)),
|
||
'sheets': len(sheets),
|
||
'ticket_pages': len(ticket_pages),
|
||
'other_sheets': len(other_sheets),
|
||
'ticket_sets': len(pages_by_mark),
|
||
},
|
||
'pieces_without_ticket': pieces_without_ticket,
|
||
'controls_without_piece': controls_without_piece,
|
||
'marks_without_ticket': marks_without_ticket,
|
||
'mark_count_mismatch': mark_count_mismatch,
|
||
'quantity_disagreements': quantity_disagreements,
|
||
'design_disagreements': design_disagreements,
|
||
'page_set_problems': page_set_problems,
|
||
'stated_page_counts': stated_page_counts,
|
||
'state_contradictions': state_contradictions,
|
||
'shared_controls': shared_controls,
|
||
'pieces_without_control': [
|
||
{'id': p.get('id'), 'mark': p.get('mark'),
|
||
'design': p.get('design'), 'family': p.get('family')}
|
||
for p in no_control],
|
||
'unreadable_control_slots': unreadable_slots,
|
||
'silent_ticket_pages': silent_pages,
|
||
'coverage_by_design': coverage,
|
||
# What was LEFT ALONE, kept apart from what could not be judged.
|
||
# Both are printed; neither is a pass.
|
||
'out_of_scope': out_of_scope,
|
||
'blind_spots': blind,
|
||
}
|
||
|
||
|
||
def _yes(value):
|
||
"""Tri-state read of a Yes/No parameter: True, False, or None for unset.
|
||
|
||
None is not False. 'the model never said' and 'the model said no' are
|
||
different facts and a contradiction may only be raised on the second.
|
||
"""
|
||
if value is None:
|
||
return None
|
||
text = str(value).strip().upper()
|
||
if text in ('YES', 'TRUE', '1'):
|
||
return True
|
||
if text in ('NO', 'FALSE', '0'):
|
||
return False
|
||
return None
|
||
|
||
|
||
def _first_design(by_control, controls):
|
||
for c in controls:
|
||
for g in by_control.get(c, []):
|
||
if g.get('design'):
|
||
return g.get('design')
|
||
return None
|
||
|
||
|
||
# ------------------------------------------------------------------ report
|
||
#
|
||
# THE TAB-WIDE FOLD RULE, APPLIED HERE. Owner, 2026-08-30: the answer is the
|
||
# headline and is never folded; everything else follows it, collapsed; a
|
||
# section that carries a problem opens itself; a folded header still says
|
||
# what it holds. The reader's contract is that **nothing open below the
|
||
# headline means nothing needs him**. The rule itself, and why the HTML that
|
||
# implements it looks like 2003, live in `vibe.foldreport` -- not here,
|
||
# because it is not about reconciliation.
|
||
#
|
||
# WHAT THE ANSWER IS ON THIS PAGE, AND WHAT IT IS NOT.
|
||
# The verdict of a reconciliation is a set of NUMBERS: how many pieces are
|
||
# accounted for, how many printed control numbers have a piece behind them,
|
||
# and how many findings there are of each kind. It is NOT the findings
|
||
# themselves. On a job where four hundred pieces are uncovered the list runs
|
||
# to thousands of lines and the verdict is invisible inside it -- which is
|
||
# exactly the page this replaces. So the headline is the four totals plus one
|
||
# row per finding that has anything in it, and every finding's own list is a
|
||
# section underneath.
|
||
#
|
||
# WHAT COUNTS AS A PROBLEM HERE -- a non-empty finding, every one of them.
|
||
# Each names work somebody has to do or a contradiction somebody has to
|
||
# resolve; there is no finding in this module that is merely interesting.
|
||
# `findings()` is the single list they are all built from, so the counts in
|
||
# the headline and the sections below it cannot drift apart.
|
||
#
|
||
# A CHECK THAT FOUND NOTHING IS NOT AN EMPTY BLOCK. The old report printed
|
||
# thirteen headings and twelve 'none's, which is precisely the noise the
|
||
# owner asked to be rid of. It is not deleted either -- 'ran and found none'
|
||
# and 'never ran' must stay distinguishable -- so every empty finding is
|
||
# named, by name, in one folded line under CHECKS THAT RAN AND FOUND NOTHING.
|
||
#
|
||
# THE TWO SECTIONS THAT SAY WHAT THIS RUN COULD NOT SEE STAY SEPARATE, AND
|
||
# CARRY THEIR COUNTS ON THEIR HEADERS EVEN WHEN FOLDED. 'Not judged, out of
|
||
# scope' and 'could not be judged' are different states and neither is a
|
||
# pass; a fold that reduced either to the word 'details' would do exactly
|
||
# what this project has already been bitten by. So their summaries are the
|
||
# numbers themselves -- `OUT OF SCOPE: 1897 model element(s) -- BM 1056, ...`
|
||
# -- readable with the section shut. And a blind spot that is a REFUSAL
|
||
# (nothing was reconciled at all) is a problem: it opens itself, and it is
|
||
# said again in the headline, because a refusal reported quietly reads as a
|
||
# clean job.
|
||
#
|
||
# ASCII '--' IN A TITLE, A SUMMARY AND A PROBLEM. `foldreport` restores the
|
||
# reports' entities in those three as of 2026-08-30, so `—` would work
|
||
# now; this page keeps the plain dashes because `foldreport.md_text` leaves
|
||
# them alone and the panel renders them as themselves -- one string that is
|
||
# right in both outputs. The test below holds the page to it.
|
||
|
||
def _piece_line(r):
|
||
return '{0} {1} {2}{3}'.format(
|
||
r['control'], '/'.join(r['marks']), r['design'] or '?',
|
||
' [model says its ticket was created]'
|
||
if _yes(r['ticket_created']) else '')
|
||
|
||
|
||
def _control_line(r):
|
||
return '{0} printed on {1}'.format(r['control'], ', '.join(r['sheets']))
|
||
|
||
|
||
def _mark_line(r):
|
||
return '{0} {1} piece(s) {2} ({3})'.format(
|
||
r['mark'], r['pieces'], r['design'] or '?',
|
||
', '.join(r['controls'][:8]))
|
||
|
||
|
||
def _count_line(r):
|
||
return ('{0} ticket issues {1}, model has {2} '
|
||
'(on the ticket only: {3}; in the model only: {4})'.format(
|
||
r['mark'], r['issued'], r['modelled'],
|
||
', '.join(r['issued_only'][:6]) or '-',
|
||
', '.join(r['modelled_only'][:6]) or '-'))
|
||
|
||
|
||
def _quantity_line(r):
|
||
return '{0} {1} says {2} required, prints {3}'.format(
|
||
r['sheet'], r['page'], r['stated'], r['listed'])
|
||
|
||
|
||
def _design_line(r):
|
||
return '{0} {1} prints {2}, its pieces are {3}'.format(
|
||
r['sheet'], r['page'], r['printed'], ', '.join(r['pieces_say']))
|
||
|
||
|
||
def _page_set_line(r):
|
||
return '{0} [{1}] {2} ({3})'.format(
|
||
r['mark'], r['status'], r['message'],
|
||
', '.join(str(n) for n in r['numbers']))
|
||
|
||
|
||
def _page_count_line(r):
|
||
return '{0} states {1}, found {2}'.format(r['mark'], r['stated'],
|
||
r['found'])
|
||
|
||
|
||
def _contradiction_line(r):
|
||
return '{0} {1} {2}'.format(r['control'], '/'.join(r['marks']),
|
||
r['problem'])
|
||
|
||
|
||
def _shared_line(r):
|
||
return '{0} {1} {2} elements {3}'.format(
|
||
r['control'], '/'.join(r['marks']), r['count'],
|
||
', '.join(r['families']))
|
||
|
||
|
||
def _no_control_line(r):
|
||
return '{0} {1} {2}'.format(r['id'], r['mark'] or '?',
|
||
r['family'] or '?')
|
||
|
||
|
||
def _slot_line(r):
|
||
return '{0} {1} {2}'.format(r['sheet'], r['page'], r['token'])
|
||
|
||
|
||
def findings(recon):
|
||
"""Every check that can find something, in the order it is reported.
|
||
|
||
One list, six fields per row: key, title, the rows themselves, the phrase
|
||
saying what those rows ARE (used as the problem sentence when there are
|
||
any, and as the 'no ...' line when there are none), a renderer for one
|
||
row, and an optional lead paragraph.
|
||
|
||
The headline's counts and the sections below it are both built from this,
|
||
which is the only arrangement in which they cannot disagree.
|
||
"""
|
||
without = recon.get('pieces_without_ticket') or []
|
||
missing = [r for r in without if r.get('mark_has_ticket')]
|
||
never = [r for r in without if not r.get('mark_has_ticket')]
|
||
return [
|
||
('missing_from_ticket', 'PIECES MISSING FROM A TICKET THAT EXISTS',
|
||
missing,
|
||
'piece(s) whose mark HAS a ticket that does not print them',
|
||
_piece_line,
|
||
'The ticket exists and passes every per-ticket check. These pieces '
|
||
'are still not printed on it, so everybody believes they are '
|
||
'covered. Nothing that reads one ticket at a time can see this.'),
|
||
('never_drawn', 'PIECES WITH NO TICKET FOR THEIR MARK AT ALL',
|
||
never, 'piece(s) whose mark was never drawn at all', _piece_line,
|
||
None),
|
||
('orphan_controls', 'TICKET CONTROL NUMBERS WITH NO PIECE',
|
||
recon.get('controls_without_piece') or [],
|
||
'control number(s) printed on a ticket with no piece behind them',
|
||
_control_line, None),
|
||
('marks_without_ticket', 'MARKS WITH NO TICKET AT ALL',
|
||
recon.get('marks_without_ticket') or [],
|
||
'mark(s) with no ticket anywhere in the set', _mark_line, None),
|
||
('count_mismatch', 'COUNT DOES NOT RECONCILE',
|
||
recon.get('mark_count_mismatch') or [],
|
||
'mark(s) where the pieces issued and the pieces modelled are not '
|
||
'the same pieces', _count_line, None),
|
||
('quantity', 'TICKET QUANTITY vs CONTROL NUMBERS PRINTED',
|
||
recon.get('quantity_disagreements') or [],
|
||
'ticket page(s) stating a quantity they do not print',
|
||
_quantity_line, None),
|
||
('design', 'TICKET DESIGN NUMBER vs ITS PIECES',
|
||
recon.get('design_disagreements') or [],
|
||
'ticket page(s) whose own pieces disagree with the design number '
|
||
'printed on them', _design_line, None),
|
||
('page_sets', 'TICKET SET INCOMPLETE OR UNJUDGED',
|
||
recon.get('page_set_problems') or [],
|
||
'ticket set(s) with a page missing, or with no page markers to be '
|
||
'judged by', _page_set_line, None),
|
||
('stated_pages', 'SHEET COUNT THE TICKET STATES vs SHEETS FOUND',
|
||
recon.get('stated_page_counts') or [],
|
||
'ticket set(s) whose stated page count is not how many pages are '
|
||
'there', _page_count_line, None),
|
||
('contradictions', 'MODEL CONTRADICTS THE TICKET SET',
|
||
recon.get('state_contradictions') or [],
|
||
'piece(s) where the model and the ticket set say opposite things',
|
||
_contradiction_line, None),
|
||
('shared_controls', 'ONE CONTROL NUMBER, SEVERAL ELEMENTS',
|
||
recon.get('shared_controls') or [],
|
||
'control number(s) carried by more than one element', _shared_line,
|
||
None),
|
||
('no_control', 'ELEMENTS WITH NO CONTROL NUMBER',
|
||
recon.get('pieces_without_control') or [],
|
||
'model element(s) that could not be reconciled at all',
|
||
_no_control_line, None),
|
||
('unreadable_slots', 'CONTROL SLOTS THAT COULD NOT BE READ',
|
||
recon.get('unreadable_control_slots') or [],
|
||
'title-block slot(s) holding text this tool cannot read as a '
|
||
'control number', _slot_line, None),
|
||
]
|
||
|
||
|
||
def blind_refusal(recon):
|
||
"""A sentence when this run reconciled NOTHING, or None when it ran.
|
||
|
||
A refusal is not one blind spot among others and it is not a clean job:
|
||
it is the state in which every number on the page is meaningless. So it
|
||
opens its section and it is repeated in the headline. It is decided from
|
||
the totals as well as from the gatherer's own note, because a run can
|
||
reconcile nothing without anybody having written a note about it.
|
||
"""
|
||
for note in (recon.get('blind_spots') or []):
|
||
low = str(note).lower()
|
||
if 'nothing was reconciled' in low or 'is a refusal' in low:
|
||
# SHORT on purpose. The note itself is printed in full inside the
|
||
# blind-spot section, which this sentence forces open, and a
|
||
# header carrying two hundred characters is a header nobody
|
||
# reads.
|
||
return ('NOTHING WAS RECONCILED -- a refusal, not a clean job. '
|
||
'The blind spot below says which role is unmapped.')
|
||
totals = recon.get('totals') or {}
|
||
if not totals.get('pieces'):
|
||
return ('NOTHING WAS RECONCILED -- not one control-numbered piece '
|
||
'was found, so nothing on this page means the job is clean.')
|
||
if totals.get('sheets') and not totals.get('ticket_pages'):
|
||
return ('NO SHEET WAS RECOGNISED AS A TICKET -- {0} sheet(s) were '
|
||
'read and none of them classified as one, so every piece '
|
||
'below reads as unticketed for that reason alone.'
|
||
.format(totals.get('sheets')))
|
||
return None
|
||
|
||
|
||
def _rows_blocks(rows, render_row, limit):
|
||
"""One md block per row, and a line admitting the cap when it bites.
|
||
|
||
A truncated list always says how many it did not print, so 'the rest'
|
||
never reads as 'there is no rest'.
|
||
"""
|
||
out = [(fold.MD, render_row(row)) for row in rows[:limit]]
|
||
if len(rows) > limit:
|
||
out.append((fold.MD, '... and {0} more (not printed)'
|
||
.format(len(rows) - limit)))
|
||
return out
|
||
|
||
|
||
def render(recon, limit=25, full_path=None):
|
||
"""The reconciliation as `vibe.foldreport.Section`s, headline first.
|
||
|
||
Pure: no Revit, no output panel, no file written. `limit` caps each named
|
||
list -- the cap belongs to the PAGE, which is read, never to the file,
|
||
which is searched.
|
||
|
||
`full_path` is where the unfolded copy was written, if anywhere. It is
|
||
named on the page, so a folded section is never the only copy of a line.
|
||
"""
|
||
t = recon['totals']
|
||
checks = findings(recon)
|
||
hit = [c for c in checks if c[2]]
|
||
clear = [c for c in checks if not c[2]]
|
||
refusal = blind_refusal(recon)
|
||
|
||
# ---- the headline: the verdict, in numbers. Never folded.
|
||
head = fold.Section('headline', 'Answer', headline=True)
|
||
head.md('# JOB RECONCILIATION')
|
||
head.md('**model** — {0} control-numbered elements, {1} pieces'
|
||
.format(t['elements'], t['pieces']))
|
||
head.md('**tickets** — {0} sheets, {1} ticket pages in {2} sets'
|
||
.format(t['sheets'], t['ticket_pages'], t['ticket_sets']))
|
||
head.md('**coverage** — {0} of {1} pieces are printed on a ticket; '
|
||
'{2} are not'.format(t['pieces_covered'], t['pieces'],
|
||
t['pieces_uncovered']))
|
||
head.md('**marks** — {0} of {1} marks have a ticket'
|
||
.format(t['marks_with_ticket'], t['marks']))
|
||
if refusal:
|
||
head.md('## ' + refusal)
|
||
if hit:
|
||
head.md('## {0} finding(s) need you'.format(len(hit)))
|
||
head.table(['What', 'How many'],
|
||
[[title, len(rows)] for _k, title, rows, _p, _r, _l in hit])
|
||
head.md('Each is a section below, open. The other {0} check(s) ran '
|
||
'and found nothing.'.format(len(clear)))
|
||
elif not refusal:
|
||
head.md('## Nothing needs you')
|
||
head.md('All {0} checks ran and every one of them found nothing. '
|
||
'What this run did not judge, and what it could not judge, '
|
||
'are still below — folded, with their counts on the '
|
||
'header.'.format(len(checks)))
|
||
|
||
out = [head]
|
||
|
||
# ---- the findings. Non-empty is a problem, and a problem opens itself.
|
||
for key, title, rows, phrase, line, lead in hit:
|
||
section = fold.Section(
|
||
key, title, summary='{0} row(s)'.format(len(rows)),
|
||
problem='{0} {1}'.format(len(rows), phrase))
|
||
if lead:
|
||
section.md(lead)
|
||
for block in _rows_blocks(rows, line, limit):
|
||
section.add(block)
|
||
out.append(section)
|
||
|
||
# ---- coverage by design. A TABLE, not aligned text: `foldreport` folds
|
||
# runs of spaces inside a markdown block, so a column built out of them
|
||
# would collapse on the way to the panel.
|
||
coverage = recon.get('coverage_by_design') or {}
|
||
if coverage:
|
||
rows = []
|
||
unticketed = 0
|
||
for design in sorted(coverage):
|
||
row = coverage[design]
|
||
short = row['pieces'] - row['covered']
|
||
unticketed += short
|
||
rows.append([design, row['pieces'], row['covered'], short or ''])
|
||
out.append(fold.Section(
|
||
'coverage', 'COVERAGE BY DESIGN',
|
||
summary='{0} design(s), {1} piece(s) not on a ticket'
|
||
.format(len(coverage), unticketed),
|
||
blocks=[(fold.TABLE,
|
||
['Design', 'Pieces', 'Ticketed', 'Not ticketed'],
|
||
rows)]))
|
||
|
||
# ---- the ticket set, as inventory. A page printing no control number is
|
||
# a FACT about the set -- on a stripped continuation page it is correct
|
||
# -- so it is counted here and never reported as a failure.
|
||
silent = recon.get('silent_ticket_pages') or []
|
||
inventory = fold.Section(
|
||
'pageset', 'PAGE-SET INVENTORY',
|
||
summary='{0} ticket page(s) in {1} set(s); {2} print no control '
|
||
'number'.format(t['ticket_pages'], t['ticket_sets'],
|
||
len(silent)))
|
||
inventory.md('{0} of the {1} sheet(s) read classified as a ticket page; '
|
||
'{2} did not.'.format(t['ticket_pages'], t['sheets'],
|
||
t['other_sheets']))
|
||
if silent:
|
||
inventory.md('**Ticket pages printing no control number at all.** '
|
||
'Expected on a stripped continuation page; listed so '
|
||
'the set can be seen, not as a fault.')
|
||
for block in _rows_blocks(
|
||
silent, lambda r: '{0} {1}'.format(r['sheet'], r['page']),
|
||
limit):
|
||
inventory.add(block)
|
||
out.append(inventory)
|
||
|
||
# ---- what was swept. The gatherer's inventory of categories, kept apart
|
||
# from its blind spots: 'I looked here' is not 'I could not look'.
|
||
searched = recon.get('searched') or []
|
||
if searched:
|
||
out.append(fold.Section(
|
||
'searched', 'CATEGORIES SEARCHED',
|
||
summary='{0} line(s) of what the sweep opened'
|
||
.format(len(searched)),
|
||
blocks=[(fold.MD, note) for note in searched]))
|
||
|
||
# ---- the checks that found nothing. Folded is not gone, and a check
|
||
# that passed is not gone either: each is named, so 'found none' can
|
||
# never be mistaken for 'never ran'.
|
||
if clear:
|
||
section = fold.Section(
|
||
'clear', 'CHECKS THAT RAN AND FOUND NOTHING',
|
||
summary='{0} of {1} checks{2}'.format(
|
||
len(clear), len(checks),
|
||
' -- but nothing was reconciled, so this is not a pass'
|
||
if refusal else ''))
|
||
if refusal:
|
||
# A check finds nothing when there is nothing to check, too. On a
|
||
# refusal that is the only reason it found nothing, and a header
|
||
# counting twelve clean checks over an empty run is the exact
|
||
# shape of the bug this whole module exists to prevent.
|
||
section.md('**Nothing was reconciled on this run.** These checks '
|
||
'found nothing because there was nothing to check. It '
|
||
'is not a pass.')
|
||
for _k, title, _rows, _phrase, _r, _l in clear:
|
||
# 'ran, found none' rather than 'no <what the check looks for>':
|
||
# half these titles are already negative and 'no marks with no
|
||
# ticket' is a sentence nobody parses on the first read.
|
||
section.md('**{0}** — ran, found none'.format(title))
|
||
out.append(section)
|
||
|
||
# ---- how long it took, and where the whole thing was written.
|
||
timing = recon.get('timing') or {}
|
||
if timing or full_path:
|
||
about = fold.Section(
|
||
'run', 'ABOUT THIS RUN',
|
||
summary=('read the model in {0} s'.format(timing.get('total_s',
|
||
'?'))
|
||
if timing else 'where the full report was written'))
|
||
if timing:
|
||
about.md('Read the model in {0} s ({1} s pieces, {2} s sheets). '
|
||
'No geometry was built.'
|
||
.format(timing.get('total_s', '?'),
|
||
timing.get('pieces_s', '?'),
|
||
timing.get('sheets_s', '?')))
|
||
if full_path:
|
||
about.md('The whole report, nothing folded and nothing capped, '
|
||
'was written to `{0}`.'.format(full_path))
|
||
out.append(about)
|
||
|
||
# ---- what was left alone, and then what could not be seen. Two states,
|
||
# two sections, never merged -- and both carry their counts on the header
|
||
# so a shut fold still says how much is behind it.
|
||
oos = recon.get('out_of_scope') or {}
|
||
if oos:
|
||
section = fold.Section(
|
||
'out_of_scope',
|
||
'WHAT THIS RUN DID NOT JUDGE -- OUTSIDE THE DECLARED SCOPE',
|
||
summary=_scope_summary(oos))
|
||
section.md(oos.get('piece_line', ''))
|
||
if oos.get('declared'):
|
||
section.md(oos.get('sheet_line', ''))
|
||
for note in (oos.get('declaration') or []):
|
||
section.md(note)
|
||
out.append(section)
|
||
|
||
blind = recon.get('blind_spots') or []
|
||
spots = fold.Section(
|
||
'blind_spots', 'WHAT THIS RUN COULD NOT JUDGE',
|
||
summary=('{0} blind spot(s)'.format(len(blind)) if blind
|
||
else 'none -- every piece and every sheet was read'),
|
||
problem=refusal)
|
||
if not blind:
|
||
spots.md('nothing -- every piece and every sheet was read')
|
||
for note in blind:
|
||
spots.md(note)
|
||
out.append(spots)
|
||
return out
|
||
|
||
|
||
def _scope_summary(oos):
|
||
"""The out-of-scope header, with its counts on it.
|
||
|
||
This one is read with the fold SHUT, so it carries the number and the
|
||
breakdown rather than the word 'details'. Not judged is not clean.
|
||
"""
|
||
if not oos.get('declared'):
|
||
return ('no scope declared -- every piece was judged, including work '
|
||
'this tool has never been validated against')
|
||
count = oos.get('piece_count') or 0
|
||
if not count:
|
||
return 'none set aside -- nothing was removed from this run'
|
||
kinds = ', '.join('{0} {1}'.format(k, n)
|
||
for k, n in (oos.get('pieces') or []))
|
||
sheets = oos.get('sheet_count') or 0
|
||
return 'OUT OF SCOPE: {0} model element(s){1}{2}'.format(
|
||
count, ' -- ' + kinds if kinds else '',
|
||
'; {0} sheet(s)'.format(sheets) if sheets else '')
|
||
|
||
|
||
def format_report(recon, limit=None, full_path=None, show_all=True):
|
||
"""The reconciliation as text lines. What a file and a terminal want.
|
||
|
||
`show_all` prints every section, folded or not, which is what the copy
|
||
written for the record is FOR -- it is the reason nothing on the panel is
|
||
the only copy of anything. Pass False to read what the panel shows.
|
||
|
||
`limit` caps each named list and defaults to no cap at all: the page has
|
||
a cap because a page is read; the file has none because it is searched.
|
||
"""
|
||
if limit is None:
|
||
limit = 10 ** 9
|
||
sections = render(recon, limit=limit, full_path=full_path)
|
||
return fold.text(sections, show_all=show_all).split('\n')
|