Public demo snapshot of an in-house pyRevit extension for precast concrete shop-ticket QC (37 checks / 46 checklist rows) and a standalone panel handling solver (PCI Chapter 8). - No client, job, plant or product data: knowledge layers ship as structural templates only. - Ticket QC's model-read escalation targets a LAN model server endpoint (configurable), not a cloud provider. - Built from the private development repo via tools/make_dist.py.
This commit is contained in:
@@ -0,0 +1,777 @@
|
||||
# -*- 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
|
||||
Reference in New Issue
Block a user