52d94303bc
ci / test (push) Has been cancelled
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.
366 lines
16 KiB
Python
366 lines
16 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Panel Analysis input form -- the Reinforcement section's block builder.
|
|
|
|
WHY THIS EXISTS: the Panel Analysis input form used to show reinforcement as
|
|
one raw eccentricity number in an unlabelled box, and the owner has now said
|
|
three times it is unreadable ("太丑了", "你这么输入用户上哪儿能看懂", and
|
|
finally "全部给我做成普通人能看懂的样子" -- make every part of it something an
|
|
ordinary person can understand). This module rebuilds the Reinforcement half
|
|
of that form to read like Eriksson Wall's own printed page (strands, rebar,
|
|
mesh, interrupts, then the drawn cross-section), while staying an honest INPUT
|
|
form rather than a finished report: every optional block that is empty says so
|
|
in a sentence, exactly the way Eriksson prints "Design contains no rebar" /
|
|
"no mesh" / "no prestressing" rather than leaving a blank space that could
|
|
mean either "nothing here" or "nobody has typed it yet".
|
|
|
|
`page(values, reads=None)` returns `(blocks, problems)` and never raises.
|
|
`blocks` is a flat list of dicts drawn from a fixed, generic vocabulary (see
|
|
the button's WPF renderer) -- 'heading', 'columns', 'field', 'table', 'kv',
|
|
'sentence', 'drawing', 'rule', 'space'. An unrecognised `kind` renders as
|
|
nothing, so adding a new one here without also teaching the renderer is a
|
|
silent no-op, not a crash -- worth remembering while extending this file.
|
|
`problems` is the sentence list the button shows above the section: every
|
|
field key this module could not find in `vibe.panelanalysisform`, every
|
|
strand size with no known area, and every summary quantity (cgx, ex, ey) that
|
|
this module refuses to compute rather than invent.
|
|
|
|
WHAT THIS MODULE WILL NOT DO, on purpose, following `panelanalysisform`'s own
|
|
rule for `strand_area_in2` (a strand size it does not recognise is refused BY
|
|
NAME, never guessed, because an invented area is a fabricated capacity):
|
|
|
|
* A strand size with no published area never gets a made-up one -- the
|
|
table shows the size, refuses the area, and `problems` names the size.
|
|
* Strand horizontal position (cgx) and both eccentricities (ex, ey) are
|
|
never printed. The per-strand `y` `_prestress_from` computes is an even
|
|
spread across the panel WIDTH assumed for the Stage-0 solve, not a
|
|
position the user typed or the model measured, so presenting it as cgx
|
|
would dress up an assumption as a fact. Depth (cgy) IS printed, because
|
|
depth-to-CG is exactly what the user types per strand group -- a
|
|
count-weighted average of real entered numbers, not an invented one.
|
|
* Horizontal strand spacing is not collected anywhere on this form, so no
|
|
table here carries a "Spacing" column or any other layout that implies
|
|
it is known -- Eriksson's own rebar table has one; this module's strand
|
|
table deliberately does not, matching the real page 27 strand table
|
|
(which has none either) rather than the rebar table on the same PDF.
|
|
|
|
WHY `strand_rows` IS READ DEFENSIVELY: a sibling agent is restructuring
|
|
`panelanalysisform` to replace the single `ps_ecc_in` box with structured
|
|
strand-group rows, exposed on `values['strand_rows']` as
|
|
`[{'count', 'depth_in', 'pull_pct'}, ...]`. That key does not exist on
|
|
`values` in every revision of that module while the restructure is in
|
|
flight, so its absence is read as "waiting on input", never as an error --
|
|
this module falls back to parsing the raw `ps_rows` text itself with
|
|
`panelanalysisform.parse_strand_rows` (the same parser `validate()` uses) and
|
|
says in `problems` that it had to.
|
|
|
|
No Revit import, no WPF import, no `panelsketch` import -- only
|
|
`vibe.panelanalysisform`, for field names/labels/hints and the strand-area
|
|
table, exactly as the rest of this input form does. Must run on IronPython
|
|
2.7 in the button (`vibe.helpers` never reaches this module) AND on plain
|
|
CPython under `WillTools.extension/tests/conftest.py`'s import trick: no
|
|
f-strings, no typing, no dataclasses.
|
|
"""
|
|
|
|
from vibe import panelanalysisform as pf
|
|
|
|
|
|
# --------------------------------------------------------------- constants
|
|
|
|
# Section/field keys this module owns, all declared under the 'Reinforcement'
|
|
# section in panelanalysisform.FIELDS. Kept as a list (not re-derived from
|
|
# FIELDS by section name) so a key panelanalysisform drops or renames shows up
|
|
# as an explicit MISSING in `problems` instead of just vanishing from the page.
|
|
_STRAND_FIELD_KEYS = ('strand_rail_location', 'strand_rail_offset_in',
|
|
'ps_dia_in', 'ps_pull_lb', 'ps_count', 'ps_loss_pct')
|
|
_REBAR_FIELD_KEYS = ('mild_as',)
|
|
_MESH_FIELD_KEYS = ('mesh',)
|
|
|
|
# panelanalysisform has no field for interrupts at all yet -- not "blank",
|
|
# genuinely absent. Kept as its own name (rather than an empty tuple reused
|
|
# for "no fields") so the one `problems` sentence that explains it is written
|
|
# once, beside the constant it is about.
|
|
_INTERRUPTS_MISSING = 'interrupts'
|
|
|
|
# panelanalysisform.FIELDS' 'kind' -> the generic 'editor' vocabulary this
|
|
# page speaks. Nothing here is 'check' or 'choice' -- every Reinforcement
|
|
# field is free text or a number.
|
|
_EDITOR_OF_KIND = {'text': 'text', 'num': 'num', 'int': 'num'}
|
|
|
|
|
|
def _field_block(key, problems):
|
|
"""A 'field' block for a panelanalysisform key, or None + a MISSING note.
|
|
|
|
Reads the label/symbol/hint/required-ness straight off panelanalysisform
|
|
so this page can never drift from the form it is rendering -- a rename in
|
|
that module shows up here as a MISSING problem instead of a stale label.
|
|
"""
|
|
spec = pf._spec(key)
|
|
if spec is None:
|
|
problems.append(
|
|
"field {0!r} is not declared in panelanalysisform -- this page "
|
|
"cannot show it (MISSING)".format(key))
|
|
return None
|
|
editor = _EDITOR_OF_KIND.get(spec['kind'], 'text')
|
|
return {
|
|
'kind': 'field', 'key': key, 'label': spec['label'],
|
|
'symbol': spec['symbol'] or None, 'hint': spec['help'] or None,
|
|
'required': spec['required'], 'editor': editor, 'choices': None,
|
|
}
|
|
|
|
|
|
def _text_of(values, key):
|
|
"""The current string in `values` for `key`, '' if absent/None.
|
|
|
|
`values` is meant to be string-valued, but this page must never raise on
|
|
a caller's mistake -- a non-string value (an int typed straight into a
|
|
dict in a test, a stray object) is coerced rather than crashed on.
|
|
"""
|
|
raw = values.get(key)
|
|
if raw is None:
|
|
return ''
|
|
if not hasattr(raw, 'strip'):
|
|
try:
|
|
raw = str(raw)
|
|
except Exception:
|
|
return ''
|
|
return raw.strip()
|
|
|
|
|
|
def _num_of(values, key):
|
|
"""(float_or_None, error_or_None) for a numeric field's current text."""
|
|
text = _text_of(values, key)
|
|
if not text:
|
|
return None, None
|
|
try:
|
|
return float(text), None
|
|
except (TypeError, ValueError):
|
|
return None, 'is not a number ({0!r})'.format(text)
|
|
|
|
|
|
# ------------------------------------------------------------------ strands
|
|
|
|
def _strand_rows_from(values, problems):
|
|
"""[{'count', 'depth_in', 'pull_pct'}, ...] read defensively.
|
|
|
|
Prefers the structured `values['strand_rows']` a sibling agent is adding;
|
|
falls back to parsing the raw `ps_rows` text with panelanalysisform's own
|
|
parser (the same one `validate()` uses) when that key is absent, and says
|
|
so in `problems` -- never treats the absence as an error by itself.
|
|
Genuinely blank input (`strand_rows` absent AND `ps_rows` blank) returns
|
|
`[]` with no problem: "nobody has typed any strand groups yet" is a real,
|
|
quiet state, not a fault.
|
|
"""
|
|
raw = values.get('strand_rows')
|
|
if isinstance(raw, list):
|
|
rows = []
|
|
for entry in raw:
|
|
try:
|
|
count = int(entry['count'])
|
|
depth_in = float(entry['depth_in'])
|
|
except (KeyError, TypeError, ValueError):
|
|
problems.append(
|
|
"a strand_rows entry from the form could not be read "
|
|
"(missing or non-numeric count/depth_in) -- skipped it "
|
|
"rather than guessing its numbers")
|
|
continue
|
|
pull_raw = entry.get('pull_pct') if hasattr(entry, 'get') else None
|
|
pull_pct = None
|
|
if pull_raw is not None:
|
|
try:
|
|
pull_pct = float(pull_raw)
|
|
except (TypeError, ValueError):
|
|
problems.append(
|
|
"a strand_rows entry's pull_pct could not be read "
|
|
"({0!r}) -- treated as not given".format(pull_raw))
|
|
rows.append({'count': count, 'depth_in': depth_in,
|
|
'pull_pct': pull_pct})
|
|
return rows
|
|
|
|
# Fall back to the raw text field this page also renders as a 'field'
|
|
# block below, so the user always has somewhere to type even while the
|
|
# structured key is not yet on `values`.
|
|
text = _text_of(values, 'ps_rows')
|
|
if not text:
|
|
return []
|
|
rows, parse_problems = pf.parse_strand_rows(text)
|
|
if parse_problems:
|
|
problems.append(
|
|
"structured strand_rows was not supplied by the form; tried to "
|
|
"read the strand-group text instead and could not: " +
|
|
'; '.join(parse_problems))
|
|
return []
|
|
if rows:
|
|
problems.append(
|
|
"structured strand_rows was not supplied by the form yet; "
|
|
"parsed the raw strand-group text (ps_rows) instead")
|
|
return rows
|
|
|
|
|
|
def _strand_block(values, problems):
|
|
"""The whole Strands sub-block: rail fields, then table or sentence.
|
|
|
|
Matches the real page's own shape: the Rail Location / Rail Offset pair
|
|
prints unconditionally, and what follows is either the strand table or
|
|
the single sentence "Design contains no prestressing" -- never a table
|
|
with a header row and no data, and never a blank space.
|
|
"""
|
|
blocks = [{'kind': 'heading', 'text': 'Strands', 'level': 2}]
|
|
|
|
rail_fields = []
|
|
for key in ('strand_rail_location', 'strand_rail_offset_in'):
|
|
fb = _field_block(key, problems)
|
|
if fb is not None:
|
|
rail_fields.append([fb])
|
|
if rail_fields:
|
|
blocks.append({'kind': 'columns', 'columns': rail_fields})
|
|
|
|
rows = _strand_rows_from(values, problems)
|
|
if not rows:
|
|
blocks.append({'kind': 'sentence', 'text':
|
|
'Design contains no prestressing'})
|
|
return blocks
|
|
|
|
for key in ('ps_count', 'ps_dia_in', 'ps_pull_lb', 'ps_loss_pct'):
|
|
fb = _field_block(key, problems)
|
|
if fb is not None:
|
|
blocks.append(fb)
|
|
|
|
dia_text = _text_of(values, 'ps_dia_in')
|
|
area, area_err = pf.strand_area_in2(dia_text if dia_text else None)
|
|
if area_err:
|
|
problems.append(area_err)
|
|
|
|
pull_lb_text = _text_of(values, 'ps_pull_lb')
|
|
|
|
headers = ['Size', 'Area (in²)', 'Diameter', 'Quantity', '% Pull',
|
|
'Depth to CG (in)']
|
|
table_rows = []
|
|
for row in rows:
|
|
size_cell = dia_text if dia_text else '?'
|
|
area_cell = ('%.4f' % area).rstrip('0').rstrip('.') if area is not \
|
|
None else '?'
|
|
if row['pull_pct'] is not None:
|
|
pull_cell = ('%g%%' % row['pull_pct'])
|
|
elif pull_lb_text:
|
|
pull_cell = 'default pull ({0} lb)'.format(pull_lb_text)
|
|
else:
|
|
pull_cell = 'default pull (not entered)'
|
|
depth_cell = ('%g"' % row['depth_in'])
|
|
table_rows.append([size_cell, area_cell, size_cell,
|
|
str(row['count']), pull_cell, depth_cell])
|
|
blocks.append({'kind': 'table', 'headers': headers,
|
|
'header_groups': None, 'rows': table_rows,
|
|
'row_key': None})
|
|
|
|
# Strand summary. Aps and cgy are real derived numbers (a count-weighted
|
|
# sum/average of what the user actually typed); cgx/ex/ey are refused --
|
|
# see the module docstring for why.
|
|
if area is not None:
|
|
total_count = sum(r['count'] for r in rows)
|
|
if total_count > 0:
|
|
aps = total_count * area
|
|
cgy = sum(r['count'] * r['depth_in'] for r in rows) / \
|
|
float(total_count)
|
|
kv_rows = [
|
|
('Aps, in²', ('%.4f' % aps).rstrip('0').rstrip('.')),
|
|
('cgy, in (depth to CG)',
|
|
('%.4f' % cgy).rstrip('0').rstrip('.')),
|
|
('cgx, in', 'not shown -- horizontal strand position is not '
|
|
'collected on this form, only assumed for the '
|
|
'solve'),
|
|
('ex, in', 'not shown -- see cgx above'),
|
|
('ey, in', 'not shown -- needs a defined section centroid, '
|
|
'not derived by this page'),
|
|
]
|
|
blocks.append({'kind': 'kv', 'rows': kv_rows})
|
|
problems.append(
|
|
'strand summary: cgx and both eccentricities (ex, ey) are '
|
|
'not computed -- horizontal strand position on this panel '
|
|
'is an assumption made for the solve, not a real input, and '
|
|
'printing it as cgx would dress up that assumption as a '
|
|
'measured fact')
|
|
else:
|
|
problems.append(
|
|
'strand summary (Aps, cgy) could not be computed -- the strand '
|
|
'area is unknown (see the strand-size problem above)')
|
|
|
|
return blocks
|
|
|
|
|
|
# --------------------------------------------------------------- rebar/mesh
|
|
|
|
def _rebar_block(values, problems):
|
|
blocks = [{'kind': 'heading', 'text': 'Rebar', 'level': 2}]
|
|
fb = _field_block('mild_as', problems)
|
|
if fb is not None:
|
|
blocks.append(fb)
|
|
if not _text_of(values, 'mild_as'):
|
|
blocks.append({'kind': 'sentence', 'text':
|
|
'Design contains no rebar'})
|
|
return blocks
|
|
|
|
|
|
def _mesh_block(values, problems):
|
|
blocks = [{'kind': 'heading', 'text': 'Mesh', 'level': 2}]
|
|
fb = _field_block('mesh', problems)
|
|
if fb is not None:
|
|
blocks.append(fb)
|
|
if not _text_of(values, 'mesh'):
|
|
blocks.append({'kind': 'sentence', 'text':
|
|
'Design contains no mesh'})
|
|
return blocks
|
|
|
|
|
|
def _interrupts_block(problems):
|
|
"""The Interrupts sub-block -- no field exists on the form for this yet.
|
|
|
|
This is deliberately NOT "Design contains no interrupts": that sentence
|
|
would assert a judged, empty answer, and nothing here has judged
|
|
anything -- panelanalysisform simply has no key to read. "Could not
|
|
judge" and "judged, found nothing" must never look alike (CLAUDE.md
|
|
section 3), so this prints the honest could-not-judge sentence and names
|
|
the gap in `problems` for whoever is meant to add the field.
|
|
"""
|
|
problems.append(
|
|
"interrupts: panelanalysisform has no field for interrupts yet "
|
|
"(MISSING) -- this page cannot say whether the design has any")
|
|
return [
|
|
{'kind': 'heading', 'text': 'Interrupts', 'level': 2},
|
|
{'kind': 'sentence', 'text':
|
|
'Could not judge -- this form does not yet collect interrupts '
|
|
'data'},
|
|
]
|
|
|
|
|
|
# ------------------------------------------------------------------- public
|
|
|
|
def page(values, reads=None):
|
|
"""(blocks, problems) for the Reinforcement section of the input form.
|
|
|
|
`values` is the form's current string-valued dict (plus, once the
|
|
sibling restructure lands, a `strand_rows` list -- see the module
|
|
docstring). `reads` is accepted for signature parity with the rest of
|
|
this form's per-section builders; nothing under Reinforcement is
|
|
prefilled from the model (`panelanalysisform.PREFILLABLE` does not
|
|
include any of these keys), so it is not read here. Never raises --
|
|
every failure becomes a sentence in `problems` instead.
|
|
"""
|
|
values = values or {}
|
|
problems = []
|
|
|
|
blocks = [{'kind': 'heading', 'text': 'Reinforcement', 'level': 1}]
|
|
blocks.extend(_strand_block(values, problems))
|
|
blocks.append({'kind': 'rule'})
|
|
blocks.extend(_rebar_block(values, problems))
|
|
blocks.append({'kind': 'rule'})
|
|
blocks.extend(_mesh_block(values, problems))
|
|
blocks.append({'kind': 'rule'})
|
|
blocks.extend(_interrupts_block(problems))
|
|
blocks.append({'kind': 'space', 'h': 8})
|
|
blocks.append({
|
|
'kind': 'drawing', 'source': 'section',
|
|
'caption': 'Reinforcement Cross-Section', 'w': 480, 'h': 360,
|
|
})
|
|
|
|
return blocks, problems
|