Files
will 25592fa6c2
ci / test (push) Successful in 6m14s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
History-free snapshot of the private development repository at 77a82eb.
Code, docs and the public catalogue only: no plant or job knowledge
layers, no client, job or producer names (tools/scrub.py clean against
the widened term list; a second word-level grep clean). The intro deck is
left out because its slide images cannot be checked for job drawings.
2026-09-09 08:35:15 -05:00

778 lines
36 KiB
Python

# -*- coding: utf-8 -*-
"""Rendering a takeoff: sections that fold, and CSV to order from. No Revit.
TWO OUTPUTS BECAUSE THERE ARE TWO READERS
------------------------------------------
The page is for the person deciding whether to believe the number. The CSV is
for the person placing the order, one row per part number or bar mark, with
both figures on every row so a spreadsheet can check one against the other.
THE SHAPE, AND WHOSE IT IS
---------------------------
The owner's standing rule for every report on this tab lives in
`vibe.foldreport`, and this file obeys it rather than restating it: the
answer is the headline, biggest and first and never folded; everything else
starts CLOSED; a section that carries a problem OPENS ITSELF and says on its
header why. The reader's contract is "if nothing below the headline is open,
nothing needs me".
WHAT THAT CHANGED HERE, AND WHY IT IS NOT A LOSS
--------------------------------------------------
This report used to lead with the uncertainty -- coverage, then the
multiplier, then the quantities -- on the reasoning that a caveat under a
total is a caveat nobody read. That reasoning was right about the danger and
wrong about the cure: a page whose first three screens are caveats is a page
whose caveats are scrolled past, every press, until they are furniture. What
actually gets a caveat read is that it is the ONLY thing open.
So the order is inverted and nothing is softened:
* the headline is the order, and it carries the multiplier's basis on it,
so the totals cannot be read without meeting what scaled them;
* a REFUSAL (nobody mapped the control-number role, so the model was never
asked how many pieces a mark is) is said in the headline AND opens its
own section below with the key somebody has to map. It is never folded;
* what one exported ticket covers -- `nomenclature.ticket_unit` -- is
stated in every state, on the multiplier section's folded header and in
its body, and the section OPENS when an undeclared one cost a mark its
only source. A source the declaration withdrew is shown as withdrawn in
the sources table rather than as a source that found nothing;
* an undercount, a source whose marks match nothing here, an unclassified
family, a nesting suspicion, a mark in the model that the index never
saw -- each opens itself the moment it is non-empty;
* `markdown()` still renders EVERY section in full, folded or not. It is
what the `.md` beside the model holds, so a fold that will not open in
pyRevit's IE-era panel costs a file open and never a sentence.
WHAT IS DELIBERATELY LEFT FOLDED
---------------------------------
The by-piece breakdown, the attached-bar section, the coverage table on a
clean job, where the multiplier swept, and the basis tallies. All of it is
still printed -- one header line each saying what it holds and how much --
because folded is not gone.
"""
# pylint: disable=invalid-name
from vibe import compat
from vibe import foldreport as fold
from vibe import takeoffcore as core
CLASS_TITLE = {
core.HARDWARE: 'Hardware, by part number',
core.BAR: 'Reinforcing, by bar mark',
core.BAR_SHAPE: 'Reinforcing, bend shape only -- MARK NOT RESOLVED',
core.STRAND: 'Strand',
core.MESH: 'Welded wire fabric',
core.CAST: 'Separately cast concrete',
core.NESTED: 'Declared nested inside another family -- not ordered',
core.NOT_ORDERED: 'Declared not orderable',
core.UNCLASSIFIED: 'UNCLASSIFIED -- counted, not classified',
}
def _n(value):
return '' if value is None else str(value)
def sections(takeoff, counts=None, title='Takeoff', extras=None):
"""The takeoff as `vibe.foldreport.Section`s, headline first. Pure.
The headline is the ORDER: hardware by part number and reinforcing by bar
mark, both figures on every line, with the line that says what the
per-job column rests on printed above them. Everything else is a section
and every section is folded unless it holds something the reader has to
act on.
`extras` is what `takeoffgather.run` returns third -- where the
multiplier came from, what it could not read, and where its sources
disagree. Optional, because the core is testable without a model; when it
is absent the page simply carries less about provenance, never a
confidence it has not earned.
"""
order, ships, other = core.order_rows(
takeoff.get('totals') or [],
takeoff.get('order_attached_separately'))
return ([_headline(takeoff, counts, title, order)]
+ _multiplier_sections(takeoff, counts, extras)
+ _scope_sections(extras)
+ _coverage_sections(takeoff)
+ _quantity_sections(takeoff, order, ships, other)
+ _detail_sections(takeoff))
# --------------------------------------------------------------- the answer
def _headline(takeoff, counts, title, order):
"""The order, and the one line saying what the per-job column rests on.
Never folded, so the multiplier's basis is stated HERE and not only in
the section that owns it: a reader who takes the totals off this page and
reads nothing else must still have met the thing that scaled them.
"""
head = fold.Section('headline', title, headline=True)
head.md('# ' + title)
head.md('_Built from the reinforcing index. No model sweep of its own, '
'and nothing here was written to the model._')
for line in _basis_lines(counts):
head.md(line)
head.md('## Quantities to order — `per_mark` is one of each mark, '
'`per_job` is what the job needs')
shown = 0
for klass in (core.HARDWARE, core.BAR):
rows = [r for r in order if r['class'] == klass]
if not rows:
continue
shown += 1
head.md('### ' + CLASS_TITLE.get(klass, klass))
head.table(*_totals_table(rows))
if not shown:
head.md('**Nothing was classified as hardware or as a bar mark.** '
'Whatever this job contains is in the sections below, not in '
'this table — an empty order is a finding, not a zero.')
return head
def _basis_lines(counts):
"""What multiplied `per_mark` into `per_job`, in one or two lines.
A REFUSAL IS NOT A FOOTNOTE. When nothing sourced the multiplier every
per-job figure equals its per-mark figure, which is a whole-job
undercount wearing the costume of a total -- so it is said in the
headline, above the numbers it governs, and said again in full (with the
key somebody has to map) in the section that opens itself below.
"""
if not counts:
return ['> **No multiplier source was available. Every mark was '
'counted once. The whole per_job column is an undercount.**']
out = []
if counts.get('refusal'):
out.append('> **The multiplier could not be sourced, so every mark '
'was counted ONCE and the whole `per_job` column is an '
'UNDERCOUNT.** Why, and what to map, is in the open '
'section below.')
line = ('**`per_job` = `per_mark` x control number.** {0} mark(s), {1} '
'piece(s) on the floor, average multiplier {2}.'.format(
_n(counts.get('marks')), _n(counts.get('pieces')),
_n(counts.get('multiplier'))))
basis = _basis_summary(counts)
if basis:
line += ' Sourced from: {0}.'.format(basis)
out.append(line)
return out
def _basis_summary(counts):
"""`control_numbers 700, assumed_one 55` -- the multiplier basis, tallied.
On the headline because a multiplier that is half assumed is a different
number from one that is sourced, and the reader cannot see which from the
average alone.
"""
by_basis = (counts or {}).get('by_basis') or {}
return ', '.join('{0} {1}'.format(k, by_basis[k]) for k in sorted(by_basis))
# ------------------------------------------------------------ the multiplier
def _multiplier_sections(takeoff, counts, extras):
"""The multiplier, its provenance, and the three ways it goes wrong.
Separate sections rather than one, because each of the three failures
opens on its own condition and a fold header carries ONE sentence: a
refusal, an undercount and a source that matches nothing are three
different things to do and must not share a header that names one of
them.
"""
out = []
section = fold.Section(
'multiplier', 'The control-number multiplier',
summary=_multiplier_summary(counts))
section.md('An assembly is a MARK and a mark is one drawing. The pieces '
'on the floor are control numbers. Every quantity on this '
'page is given BOTH ways: `per_mark` is one of each mark, '
'`per_job` is what the job needs.')
for line in _ticket_unit_lines(counts):
section.md(line)
problems = [p for p in [_ticket_unit_problem(counts)] if p]
section.problem = '; '.join(problems) or None
if not counts:
# No counts at all is still a state the provenance sections describe
# -- WHERE the run looked and what it could not read is knowable even
# when nothing came back -- so this falls through rather than returns.
section.problem = ('nothing sourced the multiplier — every '
'per-job figure is its per-mark figure')
section.md('> **No multiplier source was available. Every mark was '
'counted once. The whole per_job column is an '
'undercount.**')
return [section] + _provenance_sections(extras)
if counts.get('refusal'):
# A source nobody mapped is a REFUSAL, and the sentence that names
# the key to map is printed whole. Folding must never turn that into
# an appendix, so it is the section's problem and the section opens.
problems.insert(0, 'the model was not asked how many pieces each '
'mark is — every per-job figure is an '
'UNDERCOUNT')
section.problem = '; '.join(problems)
section.md('> **The multiplier could not be sourced.** '
+ counts['refusal'])
section.table(['marks', 'pieces on the floor', 'average multiplier'],
[[_n(counts.get('marks')), _n(counts.get('pieces')),
_n(counts.get('multiplier'))]])
section.md('Where each mark\'s multiplier came from:')
by_basis = counts.get('by_basis') or {}
section.table(['basis', 'marks'],
[[k, _n(by_basis[k])] for k in sorted(by_basis)])
sources = counts.get('sources')
if sources:
# `counted as pieces` is not decoration. A withdrawn source can know
# hundreds of marks and contribute none of them, and a table that
# showed only what it knew would read as if it had.
withdrawn = set((counts.get('ticket_unit') or {}).get('withdrawn')
or ())
section.md('What each source could speak for, before the ranking '
'chose between them:')
section.table(['source', 'marks it knew', 'counted as pieces'],
[[k, _n(sources[k]),
'NO' if k in withdrawn else 'yes']
for k in sorted(sources)])
out.append(section)
join = counts.get('join') or {}
if join.get('unmatched_source'):
# The silent-and-total failure: a source that found plenty of marks,
# none of which the takeoff has heard of.
strays = fold.Section(
'multiplier-join', 'The multiplier source and this takeoff '
'disagree about what a mark is called',
summary='{0} of {1} source mark(s) matched nothing here'.format(
len(join['unmatched_source']), _n(join.get('source_marks'))),
problem='{0} mark(s) the source knows match nothing in this '
'takeoff — the per-job column may be wrong '
'everywhere'.format(len(join['unmatched_source'])))
strays.md('**{0} of the {1} marks the multiplier source knows do not '
'match any mark in this takeoff.** The takeoff keys on the '
'assembly name and the source keys on the piece mark; if '
'those have diverged, the per-job column is wrong '
'everywhere and not just where it says `assumed_one`. '
'First few: {2}.'
.format(len(join['unmatched_source']),
_n(join.get('source_marks')),
', '.join(join['unmatched_source'][:10])))
out.append(strays)
if counts.get('assumed_one'):
under = fold.Section(
'multiplier-assumed', 'Marks counted once because nothing '
'sourced them',
summary='{0} mark(s) assumed one'.format(counts['assumed_one']),
problem='{0} mark(s) have no sourced multiplier and were counted '
'once'.format(counts['assumed_one']))
under.md('> **{0} marks have no sourced multiplier and were counted '
'once.** Those lines are an UNDERCOUNT, not a quantity.'
.format(counts['assumed_one']))
marks = takeoff.get('undercounted') or []
if marks:
under.md('Named rather than tallied, because the fix is per '
'mark: {0}{1}'.format(
', '.join(marks[:40]),
' ...' if len(marks) > 40 else ''))
out.append(under)
out.extend(_provenance_sections(extras))
return out
def _multiplier_summary(counts):
if not counts:
return 'no source — every mark counted once'
bits = ['{0} mark(s), {1} piece(s), average {2}'.format(
_n(counts.get('marks')), _n(counts.get('pieces')),
_n(counts.get('multiplier')))]
basis = _basis_summary(counts)
if basis:
bits.append(basis)
plan = counts.get('ticket_unit')
if plan:
# On the FOLDED header, because what one ticket is decides whether
# the exported tickets counted at all. A reader who never opens this
# section still learns that the run knew, or did not know.
bits.append('ticket unit: {0}'.format(
plan.get('unit') if plan.get('declared') else 'NOT DECLARED'))
return ' — '.join(bits)
def _ticket_unit_lines(counts):
"""What one exported ticket is, said out loud in every state.
`nomenclature.ticket_unit` is the declared fact that decides whether a
folder of exported tickets is a count of PIECES or a count of DRAWINGS.
Declared either way it is one routine sentence; undeclared it is a
refusal printed whole, with the key somebody has to set -- because the
run could not tell which kind of folder it was reading and must not be
read as having checked.
"""
plan = (counts or {}).get('ticket_unit')
if not plan:
return []
out = []
if plan.get('refusal'):
out.append('> **What one exported ticket covers is not declared.** '
+ plan['refusal'])
elif plan.get('reason'):
out.append('_{0}_'.format(plan['reason']))
if plan.get('withdrawn'):
out.append('Not counted as a piece source on this job: {0}. Marks '
'that no remaining source covers are counted once and '
'listed as an undercount, never sourced from a withdrawn '
'one.'.format(', '.join('`{0}`'.format(b)
for b in plan['withdrawn'])))
return out
def _ticket_unit_problem(counts):
"""A sentence when the undeclared ticket unit actually COST marks.
Undeclared is always stated -- in the section body and on the folded
header. It OPENS the section when the withdrawal left marks with no
source at all, because that is the point at which the missing
declaration stopped being a formality and started moving numbers. An
undeclared key on a run with no exported tickets to withdraw costs
nothing, and opening a section for it would spend the folding contract
on a page where nothing needs the reader.
"""
plan = (counts or {}).get('ticket_unit') or {}
if not plan.get('refusal') or not plan.get('cost'):
return None
return ('{0} mark(s) lost their only multiplier source because `{1}` is '
'not declared'.format(plan['cost'], plan.get('key',
'ticket_unit')))
def _provenance_sections(extras):
"""Where the multiplier looked, what it could not read, and where its two
sources disagree. The first is routine; the other two are gaps in the
number and open themselves."""
out = []
info = (extras or {}).get('multiplier') or {}
if info.get('categories') or info.get('elements') or info.get('notes'):
where = fold.Section(
'multiplier-source', 'Where the control numbers were read from',
summary=_source_summary(info))
if info.get('categories'):
where.md('Control numbers were read from: {0}. A piece modelled '
'in any other category was not seen by this run.'
.format(', '.join(info['categories'])))
if info.get('elements'):
where.md('{0} element(s) carried a readable control field.'
.format(info['elements']))
for note in (info.get('notes') or [])[:20]:
where.md('- {0}'.format(note))
out.append(where)
unreadable = info.get('unreadable') or []
if unreadable:
section = fold.Section(
'multiplier-unreadable', 'Control fields this run could not read',
summary='{0} element(s)'.format(len(unreadable)),
problem='{0} element(s) carry a control field the multiplier '
'could not place'.format(len(unreadable)))
section.md('**{0} element(s) carry a control field this run could '
'not turn into pieces.** They are real pieces that the '
'multiplier could not place, so they are named rather '
'than dropped:'.format(len(unreadable)))
section.table(['element', 'mark', 'field', 'why'],
[[_n(r.get('id')), _n(r.get('mark')), _n(r.get('token')),
_n(r.get('why'))] for r in unreadable[:30]])
out.append(section)
rows = (extras or {}).get('disagreements') or []
if rows:
section = fold.Section(
'multiplier-disagreements', 'Where the multiplier sources '
'disagree',
summary='{0} mark(s)'.format(len(rows)),
problem='{0} mark(s) are counted differently by two sources '
'— the higher-ranked one was used'.format(len(rows)))
section.md('**The multiplier sources disagree on {0} mark(s).** The '
'higher-ranked source was used and nothing was silently '
'reconciled:'.format(len(rows)))
section.table(['mark', 'count', 'source', 'count', 'source'],
[[r['mark'], _n(r['left']), r['left_basis'],
_n(r['right']), r['right_basis']] for r in rows[:40]])
out.append(section)
return out
def _source_summary(info):
bits = []
if info.get('categories'):
bits.append(', '.join(info['categories']))
if info.get('elements'):
bits.append('{0} element(s) read'.format(info['elements']))
return ' — '.join(bits) or 'what the multiplier swept'
# ------------------------------------------------------- scope and coverage
def _scope_sections(extras):
"""What was left alone on purpose. Folded: somebody declared it.
Never merged with coverage. Work outside the declared scope was LEFT
ALONE deliberately; work the index could not judge was attempted and
failed. Neither is a quantity of zero and they are not the same sentence.
"""
info = (extras or {}).get('scope') or {}
if not info.get('line'):
return []
section = fold.Section(
'scope', 'What this takeoff did not count',
summary=_scope_summary(info))
section.md('> ' + info['line'])
if info.get('assemblies_line'):
section.md('> ' + info['assemblies_line'])
for note in (info.get('declaration') or []):
section.md('_{0}_'.format(note))
return [section]
def _scope_summary(info):
if not info.get('declared'):
return 'no scope declared — everything indexed was counted'
return '{0} mark(s) counted, {1} left alone{2}'.format(
_n(info.get('counted_marks')), _n(info.get('left_alone_marks')),
' ({0})'.format(info['label']) if info.get('label') else '')
def _coverage_sections(takeoff):
"""What the index could not see. Folded when it saw everything.
A mark that is in the model and not in the index is a HOLE IN THE JOB --
those pieces are missing from the order -- so this opens itself when
there is one, even though the section is routine on a clean job.
"""
cov = takeoff.get('coverage') or {}
unjudged = takeoff.get('unjudged') or []
problems = []
if cov.get('marks_not_indexed'):
problems.append('{0} mark(s) are in the model and NOT in these '
'quantities'.format(cov['marks_not_indexed']))
if unjudged:
problems.append('{0} indexed mark(s) could not be judged'
.format(len(unjudged)))
section = fold.Section(
'coverage', 'What this takeoff could not see',
summary='{0} mark(s) indexed, {1} judged, {2} not indexed'.format(
_n(cov.get('indexed_marks')), _n(cov.get('judged_marks')),
_n(cov.get('marks_not_indexed'))),
problem='; '.join(problems) or None)
section.table(
['', 'count'],
[['marks indexed', _n(cov.get('indexed_marks'))],
['marks judged', _n(cov.get('judged_marks'))],
['marks the index could not judge', _n(cov.get('unjudged_marks'))],
['assemblies in the model', _n(cov.get('assemblies_in_model'))],
['marks in the model but NOT indexed',
_n(cov.get('marks_not_indexed'))],
['index built at', _n(cov.get('built_at'))]])
if cov.get('marks_not_indexed'):
section.md('> The index builds only pieces marked reinforced and not '
'on hold. The marks above are in the model and are NOT in '
'these quantities. This is a hole in the job, not a '
'rounding.')
if unjudged:
section.md('Could not judge -- these are not pieces with no '
'hardware:')
section.table(['piece', 'reason'],
[[r['piece'], r['reason']] for r in unjudged[:50]])
return [section]
# ------------------------------------------------------------- the quantities
def _quantity_sections(takeoff, order, ships, other):
"""Everything countable that is not the order itself.
The order is the headline. What follows is the rest of what was counted:
the classes nobody orders by part number, the bars that arrive welded on,
and the two lists that say what the count would not swallow.
"""
out = []
rest = [r for r in order
if r['class'] not in (core.HARDWARE, core.BAR)]
if rest:
shapes = [r for r in rest if r['class'] == core.BAR_SHAPE]
section = fold.Section(
'quantities-other', 'Other quantities — strand, mesh, cast '
'and unresolved bend shapes',
summary=_class_summary(rest),
problem=('{0} reinforcing line(s) resolved to a bend shape and '
'not to a bar mark — the quantity is real and the '
'SKU is unknown'.format(len(shapes)) if shapes else None))
for klass in (core.BAR_SHAPE, core.STRAND, core.MESH, core.CAST):
rows = [r for r in rest if r['class'] == klass]
if not rows:
continue
section.md('### ' + CLASS_TITLE.get(klass, klass))
if klass == core.BAR_SHAPE:
section.md('These are reinforcing instances the index '
'counted but could not name. The family is a bend '
'shape, not a bar mark, so the quantity is real '
'and the SKU is unknown. See `docs/takeoff.md` '
'for the one index field that resolves them.')
section.table(*_totals_table(rows))
out.append(section)
attached = fold.Section(
'attached', 'Bars that arrive attached to a part',
summary=('{0} bar mark(s) counted with a parent'.format(len(ships))
if ships else 'none declared attached'))
if ships:
attached.md('Counted, and NOT on the order above: '
'`takeoff.order_attached_bars_separately` is false, so '
'these are taken to arrive welded to the parent named. '
'Flip that key if this plant buys them loose.')
attached.table(*_totals_table(ships, parent=True))
elif takeoff.get('order_attached_separately'):
attached.md('`takeoff.order_attached_bars_separately` is TRUE, so '
'attached bars are on the order above and are not '
'repeated here.')
else:
attached.md('None of the counted bar marks is declared attached to a '
'parent.')
out.append(attached)
unclassified = takeoff.get('unclassified') or []
refuses = fold.Section(
'unclassified', 'What this takeoff refuses to count',
summary=('{0} family(ies) counted but not classified'
.format(len(unclassified)) if unclassified
else 'nothing — every family matched a rule'),
problem=('{0} family(ies) were counted and none of them was '
'classified'.format(len(unclassified))
if unclassified else None))
if unclassified:
refuses.md('Every one of these was counted and none was classified. '
'They are listed by family name rather than bucketed into '
'"other", because a tidy under-count is worse than a '
'messy honest one. Classify a family by naming it in '
'`takeoff.family_class`, or exclude it in '
'`takeoff.not_ordered`.')
refuses.table(
['family', 'per_mark', 'per_job', 'marks', 'example pieces'],
[[r['family'], _n(r['per_mark_total']), _n(r['per_job_total']),
_n(r['pieces']), ', '.join(r['examples'])]
for r in unclassified])
else:
refuses.md('Nothing. Every family in the index matched a rule.')
out.append(refuses)
declared_out = [r for r in other if r['class'] != core.UNCLASSIFIED]
if declared_out:
section = fold.Section(
'declared-out', 'Declared out of the order by knowledge',
summary=_class_summary(declared_out))
section.md('Declared out of the order by knowledge, and shown so '
'that the declaration is visible rather than silent. '
'These are NOT the unclassified list above -- somebody '
'decided each of these:')
section.table(*_totals_table(declared_out))
out.append(section)
suspects = takeoff.get('nesting_suspects') or []
nesting = fold.Section(
'nesting', 'Suspected nesting — possible double counts',
summary=('{0} family pair(s) move in lockstep'.format(len(suspects))
if suspects else 'no pair shows the equal-count signature'),
problem=('{0} family pair(s) move in lockstep — one of each '
'pair is probably counted twice'.format(len(suspects))
if suspects else None))
if suspects:
nesting.md('A nested family still appears in `GetMemberIds()`, so it '
'is counted once on its own and once inside its parent. '
'The pairs below move in lockstep -- the same counts on '
'the same marks, and the count VARIES from mark to mark, '
'which is what separates a nested family from two '
'unrelated parts that happen to be one each. **Nothing '
'has been subtracted.** Confirm one and declare it in '
'`takeoff.nested_in`.')
nesting.table(
['family A', 'family B', 'direction', 'agreement', 'marks',
'instances'],
[[r['child'], r['parent'], r.get('direction', ''),
_n(r.get('agreement')), _n(r['pieces']), _n(r['instances'])]
for r in suspects[:40]])
nesting.md('`undecided` means A and B occur on the same marks in the '
'same numbers, so the arithmetic cannot say which sits '
'inside which -- only that one of the two is very '
'probably counted twice. `agreement` is the fraction of '
'marks on which the two counts match exactly: 1.0 is a '
'lockstep pair, anything less is a pair that mostly '
'agrees and disagrees somewhere worth asking about.')
else:
nesting.md('No family pair shows the equal-count signature.')
out.append(nesting)
return out
def _class_summary(rows):
"""`strand 2 line(s), welded wire fabric 1` -- what a folded header holds."""
tally = {}
for row in rows:
tally[row['class']] = tally.get(row['class'], 0) + 1
return ', '.join('{0} {1} line(s)'.format(k, tally[k])
for k in sorted(tally))
# ----------------------------------------------------------- the long tables
def _detail_sections(takeoff):
"""The two longest things on the page and the least often read: the piece
by piece breakdown and the tally of what decided each line."""
out = []
by_piece = takeoff.get('by_piece') or {}
if by_piece:
names = sorted(by_piece)
lines = sum(len(by_piece[n].get('lines') or []) for n in names)
section = fold.Section(
'by-piece', 'The takeoff piece by piece',
summary='{0} piece(s), {1} counted line(s)'.format(len(names),
lines))
section.md('One row per piece. The full per-line version -- every '
'part number on every piece -- is the `-by-piece.csv` '
'this run writes; it is a spreadsheet, not a page.')
section.table(
['piece', 'design', 'pieces', 'multiplier basis', 'lines'],
[[n, _n(by_piece[n].get('design_number')),
_n(by_piece[n].get('pieces')),
_n(by_piece[n].get('multiplier_basis')),
_n(len(by_piece[n].get('lines') or []))]
for n in names[:100]])
if len(names) > 100:
section.md('_First 100 of {0}. The rest are in the CSV; nothing '
'is dropped from the totals._'.format(len(names)))
out.append(section)
basis = takeoff.get('bases') or {}
judged = fold.Section(
'judgements', 'Every judgement this run made',
summary='{0} piece line(s) over {1} basis kind(s)'.format(
sum(basis.values()), len(basis)))
judged.md('`declared` means a knowledge key named the family outright. '
'`rule:*` means a pattern decided it. `none` means nothing did.')
judged.table(['basis', 'piece lines decided'],
[[k, _n(basis[k])] for k in sorted(basis)])
out.append(judged)
return out
# ----------------------------------------------------------- as one document
def markdown(takeoff, counts=None, title='Takeoff', extras=None):
"""The whole takeoff as one markdown document, NOTHING folded.
The same sections the button folds, flattened. This is what goes in the
`.md` beside the model and what a reader opens when a fold will not open,
so it may never say less than the page: `show_all=True` is the point of
it.
"""
out = []
for block in fold.blocks(
sections(takeoff, counts, title=title, extras=extras),
show_all=True):
if block[0] == fold.TABLE:
out.append(_table(block[1], block[2]))
else:
out.append(block[1])
out.append('')
return '\n'.join(out)
def _totals_table(rows, parent=False):
"""(columns, rows) for a quantities table. Both figures, always, and the
ratio between them so the reader can see which multiplied and which did
not."""
head = ['key', 'per_mark', 'per_job', 'x', 'marks', 'families']
if parent:
head.append('arrives with')
body = []
for row in rows:
line = [row['key'], _n(row['per_mark_total']),
_n(row['per_job_total']), _n(row.get('multiplier')),
_n(row['marks']), ', '.join(row['families'])]
if parent:
line.append(', '.join(row.get('attached_to') or []))
body.append(line)
return head, body
def _table(head, body):
out = ['| ' + ' | '.join(head) + ' |',
'| ' + ' | '.join(['---'] * len(head)) + ' |']
for row in body:
out.append('| ' + ' | '.join(
(r if isinstance(r, compat.string_types) else _n(r)).replace('|', '\\|')
for r in row) + ' |')
return '\n'.join(out)
# --------------------------------------------------------------------- csv
CSV_HEAD = ('class', 'key', 'per_mark_total', 'per_job_total', 'marks',
'multiplier', 'on_order', 'arrives_with', 'families', 'bases')
def csv_totals(takeoff):
"""One row per part number or bar mark. Both figures on every row."""
order, ships, other = core.order_rows(
takeoff.get('totals') or [],
takeoff.get('order_attached_separately'))
on_order = set(id(r) for r in order)
rows = [','.join(CSV_HEAD)]
for row in (takeoff.get('totals') or []):
rows.append(','.join([
_csv(row['class']), _csv(row['key']),
_csv(row['per_mark_total']), _csv(row['per_job_total']),
_csv(row['marks']), _csv(row.get('multiplier')),
'yes' if id(row) in on_order else 'no',
_csv('; '.join(row.get('attached_to') or [])),
_csv('; '.join(row.get('families') or [])),
_csv('; '.join(row.get('bases') or [])),
]))
return '\n'.join(rows)
PIECE_CSV_HEAD = ('piece', 'design_number', 'pieces', 'multiplier_basis',
'class', 'key', 'family', 'per_mark', 'per_job', 'basis',
'attached_to')
def csv_by_piece(takeoff):
"""One row per piece per line. The version somebody checks a piece against."""
rows = [','.join(PIECE_CSV_HEAD)]
by_piece = takeoff.get('by_piece') or {}
for name in sorted(by_piece):
entry = by_piece[name]
if not entry.get('lines'):
rows.append(','.join([
_csv(name), _csv(entry.get('design_number')),
_csv(entry.get('pieces')), _csv(entry.get('multiplier_basis')),
'COULD_NOT_JUDGE', '', '', '', '', '', '']))
continue
for line in entry['lines']:
rows.append(','.join([
_csv(name), _csv(entry.get('design_number')),
_csv(entry.get('pieces')), _csv(entry.get('multiplier_basis')),
_csv(line['class']), _csv(line['key']), _csv(line['family']),
_csv(line['per_mark']), _csv(line['per_job']),
_csv(line['basis']),
_csv('; '.join(line.get('attached_to') or [])),
]))
return '\n'.join(rows)
def _csv(value):
text = '' if value is None else str(value)
if ',' in text or '"' in text or '\n' in text:
return '"' + text.replace('"', '""') + '"'
return text