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.
701 lines
25 KiB
Python
701 lines
25 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""Host tests for `vibe.panelanalysisform` -- the Panel Analysis input form's
|
||
pure logic: pre-fill, the completeness gate, the deck build, the Analyze-All
|
||
batch plan, the plain-language section/group structure, the Handling stage
|
||
matrix, and the openings / strand-group row parsers.
|
||
|
||
The WPF form itself (the accordion, the per-panel Run Analysis buttons, the
|
||
live geometry read off the framing element) is owner-must-verify in Revit;
|
||
everything below the form boundary is here. Loaded by path like the sibling
|
||
`test_panelanalysis*` files -- host CPython reaching `tools/` for the schema so
|
||
a deck this module builds can be fed straight back through the real
|
||
`panelanalysis/1` reader and solve.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import sys
|
||
|
||
import pytest
|
||
|
||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||
TOOLS = os.path.join(ROOT, 'tools')
|
||
PANELLIFT_SRC = os.path.join(ROOT, 'panellift', 'src')
|
||
for p in (TOOLS, PANELLIFT_SRC):
|
||
if p not in sys.path:
|
||
sys.path.insert(0, p)
|
||
|
||
from vibe import knowledge # noqa: E402,F401 (registers the vibe stub)
|
||
from vibe import panelanalysisform as F # noqa: E402
|
||
|
||
schema = pytest.importorskip('panelanalysis_schema')
|
||
statics = pytest.importorskip('panellift.statics')
|
||
|
||
|
||
# A model-read for a plain solid panel: geometry + materials the framing
|
||
# element supplied, supports blank (the user types those). Generic values.
|
||
READS = {
|
||
'mark': 'PNL101',
|
||
'design_number': 'PNL1',
|
||
'length_ft': 24.0,
|
||
'width_ft': 8.0,
|
||
'thickness_in': 8.0,
|
||
'fc_psi': 6000.0,
|
||
'fci_psi': 3500.0,
|
||
'unit_weight_pcf': 150.0,
|
||
}
|
||
|
||
|
||
def _complete_values():
|
||
"""A fully-fillable form: prefilled reads + the user's supports."""
|
||
values = F.prefill(READS)
|
||
values['supports_ft'] = '2, 22'
|
||
return values
|
||
|
||
|
||
def _with_prestress(values=None, count='4', dia='1/2', pull='28900',
|
||
rows='4 @ 2'):
|
||
values = dict(values or _complete_values())
|
||
values['ps_count'] = count
|
||
values['ps_dia_in'] = dia
|
||
values['ps_pull_lb'] = pull
|
||
values['ps_rows'] = rows
|
||
return values
|
||
|
||
|
||
# --------------------------------------------------------------- structure
|
||
|
||
def test_sections_are_the_five_eriksson_names_and_cover_every_field():
|
||
names = [name for name, _groups in F.sections()]
|
||
assert names == list(F.SECTION_ORDER)
|
||
assert names == ['Design Criteria', 'Concrete Extents',
|
||
'Structural Model', 'Handling', 'Reinforcement']
|
||
covered = set()
|
||
for _name, groups in F.sections():
|
||
for _group_name, fields in groups:
|
||
for f in fields:
|
||
covered.add(f['key'])
|
||
field_keys = set(f['key'] for f in F.FIELDS)
|
||
assert covered == field_keys
|
||
|
||
|
||
def test_sections_group_fields_and_every_group_is_named_or_none():
|
||
for _name, groups in F.sections():
|
||
group_names = [g for g, _fields in groups]
|
||
# at most one ungrouped (None) bucket, and it comes first if present
|
||
assert group_names.count(None) <= 1
|
||
if None in group_names:
|
||
assert group_names[0] is None
|
||
# every named group is non-empty
|
||
for g, fields in groups:
|
||
assert fields
|
||
if g is not None:
|
||
assert isinstance(g, str) and g
|
||
|
||
|
||
def test_design_criteria_has_the_four_named_groups():
|
||
by_section = dict(F.sections())
|
||
dc_groups = [g for g, _f in by_section['Design Criteria']]
|
||
for want in ('Material Properties', 'Prestress Losses',
|
||
'Load Combinations', 'Stress Limits & Factors'):
|
||
assert want in dc_groups
|
||
|
||
|
||
def test_every_field_declares_a_known_section_group_and_drives_flag():
|
||
for f in F.FIELDS:
|
||
assert f['section'] in F.SECTION_ORDER, f['key']
|
||
assert f['drives'] in ('solve', 'stage3', 'info'), f['key']
|
||
assert f['kind'] in ('text', 'num', 'int', 'supports', 'openings',
|
||
'strands'), f['key']
|
||
assert isinstance(f['label'], str) and f['label']
|
||
assert isinstance(f['help'], str) and f['help']
|
||
# the plain label carries no raw prime/subscript math -- that lives
|
||
# in `symbol` instead (the owner's "太丑了" ruling)
|
||
assert u'′' not in f['label'], f['key']
|
||
|
||
|
||
def test_wind_fields_are_present_blank_and_collected_not_yet_driving():
|
||
wind = [f for f in F.FIELDS if f['key'].startswith('wind_')]
|
||
keys = set(f['key'] for f in wind)
|
||
for want in ('wind_speed_vult', 'wind_risk_category', 'wind_exposure',
|
||
'wind_enclosure', 'wind_asce_version', 'wind_cc_chapter',
|
||
'wind_mwfrs_chapter', 'wind_pressure_psf', 'wind_suction_psf'):
|
||
assert want in keys
|
||
for f in wind:
|
||
assert f['section'] == 'Structural Model'
|
||
assert f['group'] == 'Wind'
|
||
assert f['drives'] == 'stage3' # collected, not yet driving
|
||
values = F.prefill(READS)
|
||
for k in keys:
|
||
assert values[k] == ''
|
||
|
||
|
||
def test_strip_transport_erection_sling_fields_are_gone():
|
||
# migrated INTO the stage matrix, not left sitting beside it
|
||
keys = set(f['key'] for f in F.FIELDS)
|
||
for gone in ('strip_mult', 'transport_mult', 'erection_mult',
|
||
'sling_angle', 'ps_ecc_in'):
|
||
assert gone not in keys
|
||
|
||
|
||
# ----------------------------------------------------------------- prefill
|
||
|
||
def test_prefill_fills_readable_and_blanks_the_rest():
|
||
values = F.prefill(READS)
|
||
assert values['length_ft'] == '24'
|
||
assert values['width_ft'] == '8'
|
||
assert values['thickness_in'] == '8'
|
||
assert values['fc_psi'] == '6000'
|
||
assert values['fci_psi'] == '3500'
|
||
assert values['mark'] == 'PNL101'
|
||
# supports and every stage3 / load field come up blank for the user
|
||
assert values['supports_ft'] == ''
|
||
assert values['self_weight_factor'] == ''
|
||
assert values['ps_count'] == ''
|
||
assert values['ps_rows'] == ''
|
||
assert values['openings'] == ''
|
||
|
||
|
||
def test_prefill_blanks_a_field_the_model_could_not_read():
|
||
reads = dict(READS)
|
||
reads['fci_psi'] = None
|
||
del reads['unit_weight_pcf']
|
||
values = F.prefill(reads)
|
||
assert values['fci_psi'] == ''
|
||
assert values['unit_weight_pcf'] == ''
|
||
|
||
|
||
def test_prefill_blanks_every_stage_matrix_cell():
|
||
values = F.prefill(READS)
|
||
for key in F.stage_field_keys():
|
||
assert values[key] == ''
|
||
|
||
|
||
# -------------------------------------------------------------- validation
|
||
|
||
def test_complete_form_validates_and_parses():
|
||
errors, parsed = F.validate(_complete_values())
|
||
assert errors == []
|
||
assert parsed['supports'] == [2.0, 22.0]
|
||
assert parsed['length_ft'] == 24.0
|
||
assert parsed['fc_psi'] == 6000.0
|
||
assert parsed['openings_rows'] == []
|
||
assert parsed['strand_rows'] == []
|
||
|
||
|
||
def test_blank_required_field_blocks_and_is_named():
|
||
values = _complete_values()
|
||
values['fc_psi'] = ''
|
||
errors, parsed = F.validate(values)
|
||
assert parsed is None
|
||
# The label now reads plain English; the symbol (f′c) still rides beside
|
||
# it via `display_label`. What is pinned here is that the message names
|
||
# the field, in BOTH vocabularies.
|
||
assert any('Concrete strength at 28 days' in e and 'required' in e
|
||
for e in errors)
|
||
assert any(u'f′c' in e for e in errors)
|
||
|
||
|
||
def test_fewer_than_two_supports_is_refused_by_name():
|
||
values = _complete_values()
|
||
values['supports_ft'] = '2'
|
||
errors, parsed = F.validate(values)
|
||
assert parsed is None
|
||
assert any('at least two support' in e for e in errors)
|
||
assert not F.is_complete(values)
|
||
|
||
|
||
def test_no_supports_is_refused():
|
||
values = _complete_values()
|
||
values['supports_ft'] = ''
|
||
errors, parsed = F.validate(values)
|
||
assert parsed is None
|
||
assert any('support' in e.lower() for e in errors)
|
||
|
||
|
||
def test_garbage_support_token_is_named():
|
||
values = _complete_values()
|
||
values['supports_ft'] = '2, banana'
|
||
errors, parsed = F.validate(values)
|
||
assert parsed is None
|
||
assert any('banana' in e for e in errors)
|
||
|
||
|
||
def test_stage3_field_never_blocks_the_run():
|
||
values = _complete_values()
|
||
# a wind value typed in but the solve does not use it -> still runnable,
|
||
# and even a non-numeric stray in a stage3 field must not block
|
||
values['wind_speed_vult'] = 'not a number'
|
||
values['wind_risk_category'] = 'II'
|
||
assert F.is_complete(values)
|
||
|
||
|
||
def test_bad_number_in_a_solve_field_blocks():
|
||
values = _complete_values()
|
||
values['unit_weight_pcf'] = 'heavy'
|
||
errors, parsed = F.validate(values)
|
||
assert parsed is None
|
||
assert any('Concrete unit weight' in e for e in errors)
|
||
|
||
|
||
# -------------------------------------------------------------- prestress
|
||
|
||
def test_no_prestress_fields_means_no_prestress_block():
|
||
_errors, parsed = F.validate(_complete_values())
|
||
assert parsed['prestress'] is None
|
||
assert parsed['strand_rows'] == []
|
||
|
||
|
||
def test_full_prestress_group_builds_strands():
|
||
values = _with_prestress(rows='4 @ 2')
|
||
errors, parsed = F.validate(values)
|
||
assert errors == []
|
||
ps = parsed['prestress']
|
||
assert len(ps['strands']) == 4
|
||
assert all(s['area_in2'] == 0.153 for s in ps['strands'])
|
||
assert all(s['initial_pull_lb'] == 28900.0 for s in ps['strands'])
|
||
assert all(s['z_in'] == 2.0 for s in ps['strands'])
|
||
|
||
|
||
def test_prestress_group_with_multiple_rows_and_per_row_pull_pct():
|
||
values = _with_prestress(count='8', rows='4 @ 2 @ 75\n4 @ 9.25 @ 56')
|
||
values['ps_fpu_ksi'] = '270'
|
||
errors, parsed = F.validate(values)
|
||
assert errors == []
|
||
ps = parsed['prestress']
|
||
assert len(ps['strands']) == 8
|
||
shallow = [s for s in ps['strands'] if s['z_in'] == 2.0]
|
||
deep = [s for s in ps['strands'] if s['z_in'] == 9.25]
|
||
assert len(shallow) == 4 and len(deep) == 4
|
||
# pull = pct/100 * fpu(ksi)*1000 * area, not the default ps_pull_lb
|
||
assert all(abs(s['initial_pull_lb'] - 0.75 * 270.0 * 1000.0 * 0.153) < 1
|
||
for s in shallow)
|
||
assert all(abs(s['initial_pull_lb'] - 0.56 * 270.0 * 1000.0 * 0.153) < 1
|
||
for s in deep)
|
||
|
||
|
||
def test_partial_prestress_group_is_refused_field_by_field():
|
||
values = _complete_values()
|
||
values['ps_count'] = '4' # started, but no size/pull/rows
|
||
errors, parsed = F.validate(values)
|
||
assert parsed is None
|
||
assert any('diameter' in e for e in errors)
|
||
assert any('pull' in e for e in errors)
|
||
assert any('Strand groups' in e for e in errors)
|
||
|
||
|
||
def test_unknown_strand_size_is_refused_not_guessed():
|
||
values = _with_prestress(count='2', dia='0.99', rows='2 @ 2')
|
||
errors, parsed = F.validate(values)
|
||
assert parsed is None
|
||
assert any('section area' in e for e in errors)
|
||
|
||
|
||
def test_ps_count_disagreement_with_rows_is_reported_not_resolved():
|
||
values = _with_prestress(count='5', rows='4 @ 2') # 5 != 4
|
||
errors, parsed = F.validate(values)
|
||
assert parsed is None
|
||
assert any('does not match the sum' in e for e in errors)
|
||
|
||
|
||
def test_ps_count_blank_skips_the_cross_check():
|
||
values = _with_prestress(count='', rows='4 @ 2')
|
||
errors, parsed = F.validate(values)
|
||
assert errors == []
|
||
assert len(parsed['prestress']['strands']) == 4
|
||
|
||
|
||
def test_garbage_strand_row_line_is_named_by_line_number():
|
||
values = _with_prestress(rows='4 @ 2\nbanana')
|
||
errors, parsed = F.validate(values)
|
||
assert parsed is None
|
||
assert any('line 2' in e for e in errors)
|
||
|
||
|
||
def test_strand_rows_are_kept_in_parsed_even_when_group_incomplete():
|
||
values = _complete_values()
|
||
values['ps_rows'] = '4 @ 2' # rows typed, but no dia/pull -> refused
|
||
errors, parsed = F.validate(values)
|
||
assert parsed is None # the whole panel is refused (as before)
|
||
# but strand_rows / ps_rows are not something to re-derive here; the
|
||
# raw text is preserved off `values` regardless, and validate() does not
|
||
# crash trying to reach into it.
|
||
|
||
|
||
# ------------------------------------------------------------- openings
|
||
|
||
def test_blank_openings_is_no_openings():
|
||
values = _complete_values()
|
||
values['openings'] = ''
|
||
errors, parsed = F.validate(values)
|
||
assert errors == []
|
||
assert parsed['openings_rows'] == []
|
||
assert parsed['openings'] == ''
|
||
|
||
|
||
def test_one_opening_per_line_parses_the_new_grammar():
|
||
rows, problems = F.parse_openings(
|
||
'Opening, 2, start, 3, 4, left, 5, 1, 6, Door\n'
|
||
'Reveal, 1, end, 2, 1, right, 2, 0, 1')
|
||
assert problems == []
|
||
assert len(rows) == 2
|
||
door = rows[0]
|
||
assert door == {'type': 'Opening', 'pos_along': 2.0,
|
||
'pos_along_from': 'start', 'length': 3.0,
|
||
'pos_cross': 4.0, 'pos_cross_from': 'left',
|
||
'width': 5.0, 'pos_vert': 1.0, 'height': 6.0,
|
||
'label': 'Door'}
|
||
reveal = rows[1]
|
||
assert reveal['type'] == 'Reveal'
|
||
assert reveal['pos_along_from'] == 'end'
|
||
assert reveal['pos_cross_from'] == 'right'
|
||
assert reveal['label'] == ''
|
||
|
||
|
||
def test_openings_bad_type_is_named_by_line_number():
|
||
rows, problems = F.parse_openings('Doorhole, 2, start, 3, 4, left, 5, 1, 6')
|
||
assert rows == []
|
||
assert any('line 1' in p and 'type' in p for p in problems)
|
||
|
||
|
||
def test_openings_bad_datum_word_is_named():
|
||
rows, problems = F.parse_openings(
|
||
'Opening, 2, sideways, 3, 4, left, 5, 1, 6')
|
||
assert rows == []
|
||
assert any('pos_along_from' in p for p in problems)
|
||
|
||
|
||
def test_openings_all_or_nothing_across_lines():
|
||
rows, problems = F.parse_openings(
|
||
'Opening, 2, start, 3, 4, left, 5, 1, 6\nbanana')
|
||
assert rows == [] # the good line is not kept either
|
||
assert len(problems) == 1
|
||
assert 'line 2' in problems[0]
|
||
|
||
|
||
def test_openings_row_reaches_validate_parsed_output():
|
||
values = _complete_values()
|
||
values['openings'] = 'Opening, 2, start, 3, 4, left, 5, 1, 6, Door'
|
||
errors, parsed = F.validate(values)
|
||
assert errors == []
|
||
assert parsed['openings_rows'] == [{
|
||
'type': 'Opening', 'pos_along': 2.0, 'pos_along_from': 'start',
|
||
'length': 3.0, 'pos_cross': 4.0, 'pos_cross_from': 'left',
|
||
'width': 5.0, 'pos_vert': 1.0, 'height': 6.0, 'label': 'Door'}]
|
||
assert parsed['openings'] == values['openings']
|
||
|
||
|
||
def test_bad_openings_line_blocks_the_run_and_names_the_line():
|
||
values = _complete_values()
|
||
values['openings'] = 'banana'
|
||
errors, parsed = F.validate(values)
|
||
assert parsed is None
|
||
assert any('line 1' in e for e in errors)
|
||
|
||
|
||
# ------------------------------------------------------------ stage matrix
|
||
|
||
def test_stage_matrix_shape():
|
||
m = F.stage_matrix()
|
||
assert m['stages'] == ['Stripping', 'Transportation', 'Primary Erection',
|
||
'Secondary Erection']
|
||
opt_keys = [r['opt_key'] for r in m['rows']]
|
||
assert opt_keys == ['include', 'load_multiplier', 'lift_type',
|
||
'concrete_strength', 'stress_limits', 'rigging',
|
||
'sling_angle', 'ground_reaction', 'min_tilt_deg',
|
||
'max_tilt_deg', 'top_crane_carries']
|
||
for row in m['rows']:
|
||
assert set(row['cells'].keys()) == set(m['stages'])
|
||
for stage, key in row['cells'].items():
|
||
assert key == F.stage_field_key(stage, row['opt_key'])
|
||
|
||
|
||
def test_stage_matrix_offers_choices_but_never_a_default():
|
||
m = F.stage_matrix()
|
||
by_key = dict((r['opt_key'], r) for r in m['rows'])
|
||
assert by_key['lift_type']['choices'] == ('Static Lift', 'Rolling Block')
|
||
assert by_key['include']['choices'] is None
|
||
values = F.prefill(READS)
|
||
for key in F.stage_field_keys():
|
||
assert values[key] == '' # nothing pre-selected
|
||
|
||
|
||
def test_stage_matrix_load_multiplier_rides_into_deck():
|
||
values = _complete_values()
|
||
values[F.stage_field_key('Stripping', 'load_multiplier')] = '1.4'
|
||
values[F.stage_field_key('Transportation', 'load_multiplier')] = '1.5'
|
||
values[F.stage_field_key('Primary Erection', 'load_multiplier')] = '1.2'
|
||
errors, parsed = F.validate(values)
|
||
assert errors == []
|
||
deck = F.build_deck(parsed)
|
||
assert deck['handling']['stripping_multiplier'] == 1.4
|
||
assert deck['handling']['transport_multiplier'] == 1.5
|
||
assert deck['handling']['erection_multiplier'] == 1.2
|
||
|
||
|
||
def test_excluded_stage_does_not_gate_the_run_on_its_other_fields():
|
||
values = _complete_values()
|
||
values[F.stage_field_key('Secondary Erection', 'include')] = 'no'
|
||
values[F.stage_field_key('Secondary Erection', 'load_multiplier')] = \
|
||
'not a number'
|
||
assert F.is_complete(values)
|
||
|
||
|
||
def test_excluded_stage_keeps_the_typed_value_rather_than_dropping_it():
|
||
values = _complete_values()
|
||
values[F.stage_field_key('Secondary Erection', 'include')] = 'no'
|
||
values[F.stage_field_key('Secondary Erection', 'load_multiplier')] = '9.9'
|
||
errors, parsed = F.validate(values)
|
||
assert errors == []
|
||
row = parsed['stage_matrix']['Secondary Erection']
|
||
assert row['include'] is False
|
||
assert row['load_multiplier'] == 9.9 # kept, just not required/used
|
||
|
||
|
||
def test_excluded_stage_multiplier_does_not_reach_the_deck():
|
||
values = _complete_values()
|
||
values[F.stage_field_key('Stripping', 'include')] = 'no'
|
||
values[F.stage_field_key('Stripping', 'load_multiplier')] = '1.4'
|
||
errors, parsed = F.validate(values)
|
||
assert errors == []
|
||
deck = F.build_deck(parsed)
|
||
assert 'stripping_multiplier' not in deck['handling']
|
||
|
||
|
||
def test_included_stage_bad_load_multiplier_blocks_and_names_the_stage():
|
||
values = _complete_values()
|
||
values[F.stage_field_key('Stripping', 'load_multiplier')] = 'heavy'
|
||
errors, parsed = F.validate(values)
|
||
assert parsed is None
|
||
assert any('Stripping' in e and 'Load multiplier' in e for e in errors)
|
||
|
||
|
||
def test_bad_include_answer_is_named():
|
||
values = _complete_values()
|
||
values[F.stage_field_key('Stripping', 'include')] = 'maybe'
|
||
errors, parsed = F.validate(values)
|
||
assert parsed is None
|
||
assert any('Stripping' in e for e in errors)
|
||
|
||
|
||
# ------------------------------------------------------------- deck build
|
||
|
||
def _deck_from(values):
|
||
errors, parsed = F.validate(values)
|
||
assert errors == [], errors
|
||
return F.build_deck(parsed)
|
||
|
||
|
||
def test_build_deck_is_the_panelanalysis_schema():
|
||
deck = _deck_from(_complete_values())
|
||
assert deck['schema'] == F.SCHEMA == schema.SCHEMA
|
||
schema.check_schema(deck) # does not raise
|
||
panel = schema.to_panel(deck)
|
||
assert panel.mark == 'PNL101'
|
||
assert panel.length_ft == 24.0
|
||
assert panel.fc_psi == 6000.0
|
||
|
||
|
||
def test_deck_supports_reach_the_schema():
|
||
deck = _deck_from(_complete_values())
|
||
assert schema.supports(deck) == [(2.0, 'support'), (22.0, 'support')]
|
||
|
||
|
||
def test_deck_solves_and_reactions_sum_to_load():
|
||
deck = _deck_from(_complete_values())
|
||
panel = schema.to_panel(deck)
|
||
stage = schema.handling_stage(deck, panel)
|
||
analysis = statics.analyse(panel, stage, schema.handling_rigging())
|
||
total_r = sum(line.reaction_lb for line in analysis.rig.lines)
|
||
total_load = analysis.profile.total_lb
|
||
# the closure that says the solve is real: reactions sum to the load
|
||
assert abs(total_r - total_load) <= max(1.0, 1e-6 * abs(total_load))
|
||
assert total_load > 0
|
||
|
||
|
||
def test_unbuilt_blocks_are_explicit_unread_not_fabricated():
|
||
deck = _deck_from(_complete_values())
|
||
for block in (deck['in_place'], deck['loading']['wind'],
|
||
deck['structural_model']['restraints']):
|
||
assert block['value'] is None
|
||
assert block['reason']
|
||
|
||
|
||
def test_openings_never_reach_the_deck_yet():
|
||
values = _complete_values()
|
||
values['openings'] = 'Opening, 2, start, 3, 4, left, 5, 1, 6, Door'
|
||
deck = _deck_from(values)
|
||
assert deck['concrete_extents']['blockouts'] == []
|
||
|
||
|
||
def test_prestress_deck_reads_as_a_prestress_panel():
|
||
values = _with_prestress(count='2', rows='2 @ 2')
|
||
deck = _deck_from(values)
|
||
panel = schema.to_panel(deck)
|
||
assert panel.prestress is not None
|
||
assert len(panel.prestress.strands) == 2
|
||
|
||
|
||
# ------------------------------------------------------------------ batch
|
||
|
||
# ------------------------------------------------------------------- wind
|
||
|
||
def test_new_asce_fields_are_present_blank_and_stage3():
|
||
keys = set(f['key'] for f in F.FIELDS)
|
||
for want in ('wind_mean_roof_height_ft', 'wind_effective_wind_area_ft2'):
|
||
assert want in keys
|
||
spec = F._spec(want)
|
||
assert spec['section'] == 'Structural Model'
|
||
assert spec['drives'] == 'stage3'
|
||
values = F.prefill(READS)
|
||
assert values['wind_mean_roof_height_ft'] == ''
|
||
assert values['wind_effective_wind_area_ft2'] == ''
|
||
|
||
|
||
def test_wind_gcpi_matches_windload_table_exactly():
|
||
# The form's GCpi table is a mirror of windload's; a host test pins it so
|
||
# the two published-code tables can never drift.
|
||
windload = pytest.importorskip('panellift.windload')
|
||
for key, val in windload._GCPI.items():
|
||
got, cite = F.wind_gcpi(key)
|
||
assert got == val, key
|
||
assert cite
|
||
|
||
|
||
def test_wind_gcpi_display_for_each_enclosure_and_spellings():
|
||
assert F.wind_gcpi('enclosed')[0] == 0.18
|
||
assert F.wind_gcpi('partially_enclosed')[0] == 0.55
|
||
assert F.wind_gcpi('Partially Enclosed')[0] == 0.55 # spacing/case
|
||
assert F.wind_gcpi('partial-open')[0] == 0.18 # alias + dash
|
||
assert F.wind_gcpi('open')[0] == 0.00
|
||
|
||
|
||
def test_wind_gcpi_blank_and_unknown_are_could_not_judge_not_zero():
|
||
val, why = F.wind_gcpi('')
|
||
assert val is None and why # not 0.0
|
||
val, why = F.wind_gcpi('mostly closed')
|
||
assert val is None and 'not recognized' in why
|
||
|
||
|
||
def test_resolve_wind_pressure_direct_beats_computed():
|
||
d = F.resolve_wind_pressure(31.0, 28.3)
|
||
assert d['source'] == 'direct' and d['pressure_psf'] == 31.0
|
||
|
||
|
||
def test_resolve_wind_pressure_computed_when_no_direct():
|
||
d = F.resolve_wind_pressure(None, 28.3)
|
||
assert d['source'] == 'computed' and d['pressure_psf'] == 28.3
|
||
|
||
|
||
def test_resolve_wind_pressure_not_judged_passes_reason_through():
|
||
d = F.resolve_wind_pressure(None, None, 'NOT JUDGED: h > 60 ft ...')
|
||
assert d['source'] == 'not_judged'
|
||
assert d['pressure_psf'] is None # never fabricated
|
||
assert 'NOT JUDGED' in d['reason']
|
||
|
||
|
||
def test_resolve_wind_pressure_direct_wins_even_over_not_judged():
|
||
d = F.resolve_wind_pressure(31.0, None, 'NOT JUDGED: h > 60 ft ...')
|
||
assert d['source'] == 'direct' and d['pressure_psf'] == 31.0
|
||
|
||
|
||
def test_resolve_wind_pressure_blank_when_nothing_supplied():
|
||
d = F.resolve_wind_pressure(None, None, None)
|
||
assert d['source'] == 'blank' and d['pressure_psf'] is None
|
||
assert d['reason']
|
||
|
||
|
||
def _wind_values():
|
||
values = _complete_values()
|
||
values['wind_speed_vult'] = '115'
|
||
values['wind_risk_category'] = 'II'
|
||
values['wind_exposure'] = 'C'
|
||
values['wind_enclosure'] = 'enclosed'
|
||
values['wind_asce_version'] = '7-16'
|
||
values['wind_cc_chapter'] = '30'
|
||
values['wind_mean_roof_height_ft'] = '30'
|
||
values['wind_effective_wind_area_ft2'] = '100'
|
||
return values
|
||
|
||
|
||
def test_build_deck_carries_asce_wind_inputs_when_supplied():
|
||
deck = _deck_from(_wind_values())
|
||
wind = deck['loading']['wind']
|
||
assert 'value' not in wind # no longer unread
|
||
a = wind['asce']
|
||
assert a['v_ult_mph'] == 115.0
|
||
assert a['exposure'] == 'C'
|
||
assert a['enclosure'] == 'enclosed'
|
||
assert a['code_version'] == '7-16'
|
||
assert a['mean_roof_height_ft'] == 30.0
|
||
assert a['effective_wind_area_ft2'] == 100.0
|
||
|
||
|
||
def test_build_deck_carries_direct_pressure_override():
|
||
values = _wind_values()
|
||
values['wind_pressure_psf'] = '45'
|
||
values['wind_suction_psf'] = '50'
|
||
deck = _deck_from(values)
|
||
wind = deck['loading']['wind']
|
||
assert wind['direct_pressure_psf'] == 45.0
|
||
assert wind['direct_suction_psf'] == 50.0
|
||
|
||
|
||
def test_partial_asce_without_the_required_numbers_is_not_computed():
|
||
values = _complete_values()
|
||
values['wind_exposure'] = 'C' # something typed, but no Vult/h/area
|
||
deck = _deck_from(values)
|
||
wind = deck['loading']['wind']
|
||
assert 'asce' not in wind
|
||
assert 'asce_incomplete' in wind # says what is still needed
|
||
|
||
|
||
def test_no_wind_inputs_keeps_the_explicit_unread_placeholder():
|
||
deck = _deck_from(_complete_values())
|
||
assert deck['loading']['wind']['value'] is None
|
||
assert deck['loading']['wind']['reason']
|
||
|
||
|
||
def test_restraints_field_reaches_the_deck_when_filled():
|
||
values = _wind_values()
|
||
values['restraints'] = '0, 30'
|
||
deck = _deck_from(values)
|
||
r = deck['structural_model']['restraints']
|
||
assert r == [{'x_ft': 0.0, 'type': 'restraint'},
|
||
{'x_ft': 30.0, 'type': 'restraint'}]
|
||
|
||
|
||
def test_no_restraints_keeps_the_explicit_unread_placeholder():
|
||
deck = _deck_from(_complete_values())
|
||
assert deck['structural_model']['restraints']['value'] is None
|
||
|
||
|
||
def test_batch_plan_runs_complete_and_skips_incomplete_with_reasons():
|
||
good = _complete_values()
|
||
bad = _complete_values()
|
||
bad['supports_ft'] = '5' # only one support
|
||
worse = _complete_values()
|
||
worse['fc_psi'] = '' # missing material
|
||
named = [('PNL101', good), ('PNL102', bad), ('PNL103', worse)]
|
||
to_run, skipped = F.batch_plan(named)
|
||
run_labels = [lbl for lbl, _p in to_run]
|
||
skip_labels = [lbl for lbl, _r in skipped]
|
||
assert run_labels == ['PNL101']
|
||
assert set(skip_labels) == {'PNL102', 'PNL103'}
|
||
reasons = dict((lbl, errs) for lbl, errs in skipped)
|
||
assert any('support' in e for e in reasons['PNL102'])
|
||
assert any(u'f′c' in e for e in reasons['PNL103'])
|
||
|
||
|
||
# ------------------------------------------------------------ display_label
|
||
|
||
def test_display_label_carries_both_label_and_symbol():
|
||
d = F.display_label('fc_psi')
|
||
assert 'Concrete strength at 28 days' in d
|
||
assert u'f′c' in d
|
||
|
||
|
||
def test_display_label_falls_back_to_label_when_no_symbol():
|
||
d = F.display_label('concrete_type')
|
||
assert d == 'Concrete type'
|