8e6ee31596
ci / test (push) Successful in 6m18s
History-free snapshot of the private development repository at cbe45c5 (2026-09-09). Code, docs and public catalogue only; no plant or job knowledge layers, no client or job names (tools/scrub.py clean). The intro deck is left out of this snapshot because its slide images could not be checked for job drawings.
1585 lines
74 KiB
Python
1585 lines
74 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Joining the reinforcing index to the matcher: features, the real `covers`,
|
|
the donor filter, and the end-to-end path from a piece name to proposals.
|
|
|
|
WHAT A FEATURE IS HERE, AND WHY IT IS NOT A VOID
|
|
------------------------------------------------
|
|
Owner's reframe, 2026-08-30: what gets reinforced is the CONCRETE THAT IS
|
|
LEFT, not the hole. Two openings 3 ft apart leave a 3 ft strip that has to
|
|
carry load and take bars; the same two openings 8 in apart leave a little
|
|
column section that may not develop a strand at all. The two holes are
|
|
identical in both cases. The strip between them is the thing an engineer
|
|
details, and it belongs to neither hole.
|
|
|
|
So the features this module hands the matcher are LIGAMENTS -- strips of
|
|
remaining concrete -- built from the index's voids rather than being the
|
|
voids. A ligament is the strip between two voids, or between a void and a
|
|
panel edge.
|
|
|
|
LOCALITY NEEDS NO RADIUS. There is no neighbourhood size to tune here and
|
|
none is invented: a ligament's extent is fully determined by the two things
|
|
that bound it. The strip between two voids spans the overlap of their ranges
|
|
on one axis and the gap between them on the other; both numbers fall out of
|
|
the geometry. That is the whole definition.
|
|
|
|
NO THRESHOLD. Every ligament is emitted, however narrow. The width below
|
|
which a strip is a "little column section" that cannot develop a strand is a
|
|
plant number, it lives in the local knowledge YAML, and it is not this
|
|
module's to guess -- consistent with the matcher's standing rule that
|
|
exactness RANKS and does not CLASSIFY.
|
|
|
|
A LIGAMENT MUST BE CONCRETE YOU CAN POINT AT. Every candidate strip is
|
|
checked clear of every other void before it is emitted. Without that check a
|
|
"strip" between two voids at opposite ends of a panel would be reported as
|
|
concrete while a third void sits in the middle of it. Zero-width strips are
|
|
not emitted either, because a void flush with the panel edge leaves no
|
|
concrete there; that condition shows up as the ABSENCE of an edge ligament,
|
|
which the set cover reads correctly -- a target that has the strip and a
|
|
donor that does not means the strip goes uncovered.
|
|
|
|
THE RECTANGLE ASSUMPTION, AND THE FLAG THAT KEEPS IT HONEST
|
|
------------------------------------------------------------
|
|
Ligaments are derived from `rect(length_ft, width_ft) minus the voids`. That
|
|
was measured against the model rather than assumed: comparing each panel's
|
|
actual solid volume with its extents box minus its recorded voids, 242 of the
|
|
255 WP donors agree to a median 0.00% and a p90 of 0.01%. The derivation is
|
|
sound for those.
|
|
|
|
THIRTEEN OF 255 DO NOT. Their solid is 3% to 33% smaller than their extents
|
|
box with no voids to explain it -- WP108 is 32.7% short against 0.2 cubic
|
|
feet of recorded voids. Those panels have a non-rectangular outline (a slope
|
|
or a corner cut) that the index, which stores only a bounding length and
|
|
width, cannot see. For them the derived shape claims concrete that is not
|
|
there, and it claims it exactly at the panel edge, which is where the edge
|
|
ligaments live.
|
|
|
|
Those pieces are marked `outline_approximate` and their EDGE ligaments can
|
|
never score exact (see `covers`). They still take part -- excluding them
|
|
would silently shrink the pool -- but a caller can never mistake an
|
|
approximated edge condition for a measured one. `judged` and `judged on an
|
|
approximation` stay different answers, which is the same rule the index
|
|
already applies to `features == []` against `features is None`.
|
|
|
|
KNOWN FOLLOW-UP, for whoever picks this up
|
|
-------------------------------------------
|
|
The fix is one new key on the index's piece record: the boundary of
|
|
`concrete_solid` projected into the piece frame, as a list of absolute-feet
|
|
`(x, y)` vertices, alongside the existing `length_ft` / `width_ft`. It needs
|
|
a `reinforcecore.SCHEMA` bump and a change in `reinforceindex.extract`.
|
|
Deferred deliberately on 2026-08-30 for a deadline, with the owner's
|
|
agreement that cleanup happens at integration; the evidence for doing it is
|
|
the 13-of-255 measurement above, reproducible with `outline_audit()` below.
|
|
Until it lands, `outline_approximate` is what stands in for it.
|
|
|
|
UNITS: FEET IN, INCHES OUT
|
|
--------------------------
|
|
Every record this module reads is in decimal FEET -- Revit's internal unit,
|
|
which is what `reinforcecore` caches and what the geometry extraction
|
|
produces. Nothing here converts a coordinate.
|
|
|
|
`covers` is the one exception and it is the seam: the distance it RETURNS is
|
|
INCHES, on a 1/8 in grain, by the owner's ruling of 2026-08-30
|
|
("all actual measurements use inches, to two decimal places"). It converts once, through
|
|
`reinforcecore.inches()`, and everything above it -- the matcher, the
|
|
proposals, the report's distance column -- is inches from there on. The
|
|
report converts stored feet coordinates for display through the same helper,
|
|
at the moment of printing, and never writes an inch value back onto a record.
|
|
So a double conversion has nowhere to live. See `GRAIN_IN` for why the grain
|
|
exists and `reinforcecore`'s seam note for the whole rule.
|
|
|
|
WHAT IS REVIT-FREE
|
|
------------------
|
|
The MODULE is: nothing at module scope imports Revit, so
|
|
`from vibe import reinforcebind` works under host CPython (that is how the
|
|
tests reach it, and how `tools/reinforce_offline.py` does).
|
|
|
|
TWO FUNCTIONS IN IT ARE NOT, and the line is drawn at the function, not at
|
|
the file, so an offline caller can be told exactly what it may call:
|
|
|
|
* `outline_audit(doc, ...)` -- reads each panel's real solid through
|
|
`liftexport.concrete_solid`. There is no way to compute a volume from a
|
|
snapshot that stores only a bounding rectangle; that is the whole reason
|
|
the audit exists. Offline, `load_audit_at(folder)` reads the RESULT it
|
|
left on disk, which is all a suggestion needs.
|
|
* `suggest_for_piece(doc, ...)` -- measures the target through the document,
|
|
and resolves the index path through `reinforceindex.index_path` ->
|
|
`designspec`, which imports `Autodesk.Revit.DB` at module scope.
|
|
|
|
`suggest_from_index` is the whole of `suggest_for_piece` except those two
|
|
steps, and it takes a cache instead of a document. Both of them only READ --
|
|
no Transaction, no parameter write, nothing in this feature writes to the
|
|
model.
|
|
"""
|
|
# pylint: disable=invalid-name,broad-except
|
|
|
|
from vibe import reinforcecore as core
|
|
from vibe import reinforcematch as match
|
|
# Revit-free, like the two above -- it reaches only `yamlkit` and `compat`.
|
|
# It is here for `design_key`, which is this codebase's ONE answer to "are
|
|
# these two design numbers the same design?" and which lived in `designspec`
|
|
# (Revit at module scope) until 2026-09-01. See `design_family` below.
|
|
from vibe import specformat
|
|
|
|
# Two lengths this close are the same length as far as reinforcing goes:
|
|
# 1/32 in in feet, under the 3-decimal rounding the index already applies.
|
|
EXACT_TOL_FT = 0.003
|
|
# The same tolerance on the other side of the unit seam. `covers` returns
|
|
# inches, so the "close but not exact" floor has to be quoted in inches too.
|
|
EXACT_TOL_IN = core.inches(EXACT_TOL_FT) # 0.036 in
|
|
|
|
# THE GRAIN, and the whole answer to "two decimals does not mean 0.01 in".
|
|
#
|
|
# The owner ruled the DISPLAY: "实际测量全部用英寸,带两位小数". Two decimal
|
|
# inches is 0.01", which is an order of magnitude finer than any tolerance
|
|
# this tool uses anywhere -- dimensions are checked at 3/8" (0.375") and
|
|
# interference at 1/8" (0.125"). Printing a ranking driven by 0.01" would be
|
|
# claiming a precision the measurement does not have, and this project has
|
|
# been burned before by a criterion that measured confidently and wrongly.
|
|
#
|
|
# So the DISPLAY does what he asked and the MEASUREMENT does not pretend:
|
|
# every non-exact distance `covers` returns is rounded to the nearest 1/8 in,
|
|
# the finest tolerance the tool actually works to. Two consequences, both
|
|
# wanted:
|
|
#
|
|
# * the ranking cannot be steered by a difference smaller than 1/16 in.
|
|
# Two donors that differ by less than that rank EQUAL and fall through to
|
|
# the next tie-break (MASTER, then input order) instead of being separated
|
|
# by noise.
|
|
# * a printed distance can only ever be a multiple of 1/8 in -- 0.13, 0.25,
|
|
# 0.38, 0.50 ... -- so the two decimals the owner asked for are visibly
|
|
# granular on the page. The report says the grain out loud as well
|
|
# (`reinforcereport.GRAIN_NOTE`), but a reader who never reads a footnote
|
|
# still cannot mistake this for 0.01 in resolution.
|
|
#
|
|
# EXACTNESS IS NOT QUANTIZED. Rounding to 1/8 in would make anything under
|
|
# 1/16 in read as 0.0, i.e. as EXACT, and "exact" is the one word in this
|
|
# report that has to keep meaning measured-identical. So the exact test stays
|
|
# at EXACT_TOL (1/32 in) and quantization applies only above it, with a floor
|
|
# so a non-exact match can never quantize down to zero.
|
|
GRAIN_IN = 0.125
|
|
|
|
# A strip narrower than this is not concrete, it is two voids touching.
|
|
MIN_STRIP_FT = 0.005
|
|
|
|
# Feature kinds. `covers` never compares across kinds -- a strip and an
|
|
# opening are not the same thing however well their rectangles line up.
|
|
OPENING = 'opening'
|
|
STRIP_X = 'strip_x' # width measured along the piece's length
|
|
STRIP_Y = 'strip_y' # width measured across the piece's width
|
|
|
|
VOID_VOID = 'void-void'
|
|
VOID_EDGE = 'void-edge'
|
|
|
|
|
|
# ------------------------------------------------------------ the features
|
|
|
|
def features_for(piece, outline_approximate=False, include_voids=True):
|
|
"""The feature list for one index piece record: ligaments, and the voids.
|
|
|
|
Returns None when the piece was never measured (`features is None`) --
|
|
could-not-judge propagates rather than becoming an empty list. Returns a
|
|
list, possibly empty, when it was measured.
|
|
|
|
JUDGEMENT CALL, flagged for review: the voids themselves are kept as
|
|
features alongside the ligaments (`include_voids`, default True). The
|
|
reframe says the strip is what gets detailed, and it does; but an opening
|
|
still needs trim bars sized to the opening, so dropping the hole entirely
|
|
would lose a real requirement. Keeping both means a donor has to account
|
|
for the target's holes AND the concrete between them. Pass
|
|
`include_voids=False` to get ligaments only.
|
|
"""
|
|
voids = (piece or {}).get('features')
|
|
if voids is None:
|
|
return None
|
|
L = _opt(piece.get('length_ft'))
|
|
W = _opt(piece.get('width_ft'))
|
|
out = []
|
|
if include_voids:
|
|
for v in voids:
|
|
out.append(_record(piece, OPENING, v['x0'], v['x1'],
|
|
v['y0'], v['y1'], None, None,
|
|
through=v.get('through'),
|
|
concrete_in=v.get('concrete_in'),
|
|
outline_approximate=outline_approximate))
|
|
if L is not None and W is not None:
|
|
out.extend(_ligaments(piece, voids, L, W, outline_approximate))
|
|
for i, f in enumerate(out):
|
|
f['index'] = i
|
|
return out
|
|
|
|
|
|
def _ligaments(piece, voids, L, W, outline_approximate):
|
|
"""Every strip of concrete bounded by two voids, or by a void and an edge.
|
|
|
|
Both axes, because a strip between two voids stacked vertically is as real
|
|
as one between two side by side, and the index records both axes precisely
|
|
so this cannot be got wrong in one of them.
|
|
"""
|
|
out = []
|
|
n = len(voids)
|
|
|
|
# void to void: an x-gap needs the two to overlap in y, and the reverse.
|
|
for i in range(n):
|
|
for j in range(i + 1, n):
|
|
a, b = voids[i], voids[j]
|
|
for lo, hi in ((a, b), (b, a)):
|
|
if lo['x1'] <= hi['x0']:
|
|
span0 = max(lo['y0'], hi['y0'])
|
|
span1 = min(lo['y1'], hi['y1'])
|
|
if span1 - span0 > MIN_STRIP_FT:
|
|
_add(out, piece, STRIP_X, lo['x1'], hi['x0'],
|
|
span0, span1, VOID_VOID, None, voids, (i, j),
|
|
outline_approximate)
|
|
if lo['y1'] <= hi['y0']:
|
|
span0 = max(lo['x0'], hi['x0'])
|
|
span1 = min(lo['x1'], hi['x1'])
|
|
if span1 - span0 > MIN_STRIP_FT:
|
|
_add(out, piece, STRIP_Y, span0, span1,
|
|
lo['y1'], hi['y0'], VOID_VOID, None, voids,
|
|
(i, j), outline_approximate)
|
|
|
|
# void to edge, all four edges of the piece.
|
|
for i in range(n):
|
|
v = voids[i]
|
|
_add(out, piece, STRIP_X, 0.0, v['x0'], v['y0'], v['y1'],
|
|
VOID_EDGE, 'x0', voids, (i,), outline_approximate)
|
|
_add(out, piece, STRIP_X, v['x1'], L, v['y0'], v['y1'],
|
|
VOID_EDGE, 'x1', voids, (i,), outline_approximate)
|
|
_add(out, piece, STRIP_Y, v['x0'], v['x1'], 0.0, v['y0'],
|
|
VOID_EDGE, 'y0', voids, (i,), outline_approximate)
|
|
_add(out, piece, STRIP_Y, v['x0'], v['x1'], v['y1'], W,
|
|
VOID_EDGE, 'y1', voids, (i,), outline_approximate)
|
|
|
|
out.sort(key=lambda f: (f['kind'], f['x0'], f['y0'], f['x1'], f['y1']))
|
|
return out
|
|
|
|
|
|
def _add(out, piece, kind, x0, x1, y0, y1, between, edge, voids, owners,
|
|
outline_approximate):
|
|
"""Emit one strip, if it is really concrete: wide enough, and clear."""
|
|
if x1 - x0 <= MIN_STRIP_FT or y1 - y0 <= MIN_STRIP_FT:
|
|
return # no concrete here to detail
|
|
if not _clear(x0, x1, y0, y1, voids, owners):
|
|
return # a third void sits in the strip
|
|
out.append(_record(piece, kind, x0, x1, y0, y1, between, edge,
|
|
outline_approximate=outline_approximate))
|
|
|
|
|
|
def _clear(x0, x1, y0, y1, voids, owners):
|
|
"""No void may overlap the strip's interior. This is what keeps a
|
|
ligament a thing you can point at on the piece."""
|
|
for k in range(len(voids)):
|
|
if k in owners:
|
|
continue
|
|
v = voids[k]
|
|
if (v['x0'] < x1 - MIN_STRIP_FT and v['x1'] > x0 + MIN_STRIP_FT and
|
|
v['y0'] < y1 - MIN_STRIP_FT and v['y1'] > y0 + MIN_STRIP_FT):
|
|
return False
|
|
return True
|
|
|
|
|
|
def _record(piece, kind, x0, x1, y0, y1, between, edge, through=None,
|
|
concrete_in=None, outline_approximate=False):
|
|
x0, x1 = _r(x0), _r(x1)
|
|
y0, y1 = _r(y0), _r(y1)
|
|
return {
|
|
'piece': piece.get('name'),
|
|
'kind': kind,
|
|
'index': None,
|
|
'x0': x0, 'x1': x1, 'y0': y0, 'y1': y1,
|
|
'w': _r(x1 - x0), 'h': _r(y1 - y0),
|
|
'cx': _r((x0 + x1) / 2.0), 'cy': _r((y0 + y1) / 2.0),
|
|
# For a strip, the width that matters is the one across it. For an
|
|
# opening it is not meaningful and stays None.
|
|
'strip_ft': (None if kind == OPENING else
|
|
_r(x1 - x0 if kind == STRIP_X else y1 - y0)),
|
|
'span_ft': (None if kind == OPENING else
|
|
_r(y1 - y0 if kind == STRIP_X else x1 - x0)),
|
|
'between': between,
|
|
'edge': edge,
|
|
'through': through,
|
|
'concrete_in': concrete_in,
|
|
'piece_length_ft': piece.get('length_ft'),
|
|
'piece_width_ft': piece.get('width_ft'),
|
|
'piece_thickness_in': piece.get('thickness_in'),
|
|
'outline_approximate': bool(outline_approximate),
|
|
}
|
|
|
|
|
|
def _r(v, places=3):
|
|
return None if v is None else round(float(v), places)
|
|
|
|
|
|
def _opt(value):
|
|
if value is None:
|
|
return None
|
|
try:
|
|
return float(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
# --------------------------------------------------------------- the compare
|
|
|
|
def _quantize(distance_in):
|
|
"""A non-exact distance, on the 1/8 in grain, never rounded down to exact.
|
|
|
|
Below `EXACT_TOL_IN` this is never called -- that case returned 0.0
|
|
already. Above it, anything that would land on zero is floored at
|
|
`EXACT_TOL_IN` instead, so "off by a hair" and "identical" stay two
|
|
different answers however coarse the grain gets.
|
|
"""
|
|
steps = int(distance_in / GRAIN_IN + 0.5)
|
|
got = steps * GRAIN_IN
|
|
if got < EXACT_TOL_IN:
|
|
return EXACT_TOL_IN
|
|
return got
|
|
|
|
|
|
def covers(donor_feature, target_feature):
|
|
"""The production `covers`. None / 0.0 / a positive distance in INCHES.
|
|
|
|
Meets the matcher's seven requirements as follows.
|
|
|
|
TOTAL (1): any pair that cannot be read returns None. It never raises.
|
|
PURE (2): arithmetic on the two records and nothing else.
|
|
DIRECTIONAL (3): the strip-width rule below is one-way on purpose.
|
|
COMMENSURABLE (4): one unit, INCHES, for every kind. The distance is a
|
|
sum of absolute lengths, so 0.5 on a strip means what 0.5 means on an
|
|
opening. No per-kind weighting exists and none is invented: the owner
|
|
ruled the unit, not a weighting.
|
|
ABSOLUTE (5): every coordinate on the RECORD is feet from the start of the
|
|
piece -- the index stores Revit's internal unit and nothing converts
|
|
it. Nothing here divides by a panel dimension.
|
|
TWO-DIMENSIONAL (6): both the centre test and the distance use x and y.
|
|
CHEAP (7): a few floats.
|
|
|
|
COVERS AT ALL, in four conditions:
|
|
|
|
a. Same kind. An opening is not a strip, and a strip whose width runs
|
|
along the panel is not one whose width runs across it, however well
|
|
the rectangles line up.
|
|
|
|
b. Each rectangle contains the other's centre. Two-dimensional on
|
|
purpose: measured on this model, two voids share an x-range while
|
|
sitting in different height bands, and an x-only test calls those the
|
|
same feature. It also does the size sanity for free.
|
|
|
|
c. For strips, the donor's strip may be NARROWER than the target's or
|
|
equal, never wider (beyond tolerance). This is the directional part
|
|
and it is the one piece of reinforcing judgement in the test: bars
|
|
detailed for a narrow strip fit in a wider one with room to spare,
|
|
while a cage laid out for a wide strip does not fit into concrete
|
|
that is not there. NOTE, because I said the opposite in an earlier
|
|
reassessment and it was wrong: the permissive direction is the narrow
|
|
donor, not the wide one. The owner's own concern -- a little column
|
|
section that cannot develop a strand -- is about narrow strips, and a
|
|
donor that solved a narrow one is a good template for a wider one.
|
|
|
|
d. Penetration must not CONTRADICT, for openings. A through opening and
|
|
a blind pocket are different reinforcing: the pocket leaves concrete
|
|
behind it that carries load and takes its own trim. `through=None` is
|
|
COULD NOT JUDGE, never False, and is never compared as if it were --
|
|
a pair with an unjudged side passes here, on the grounds that nothing
|
|
was measured to contradict it, and `explain()` reports it as unjudged.
|
|
|
|
DISTANCE is the sum of the four absolute corner offsets. Zero for
|
|
identical rectangles. The edge-condition information that an earlier
|
|
version tried to bolt on as extra distance terms is not needed: the edge
|
|
ligaments ARE that information, as their own features.
|
|
|
|
THE UNIT SEAM IS THIS FUNCTION AND ONLY THIS FUNCTION. The two records
|
|
come in as feet -- that is what `reinforcecore` stores, because feet is
|
|
Revit's internal unit and converting on the way in is one more chance at a
|
|
12x error. The sum is computed in feet, converted ONCE by
|
|
`reinforcecore.inches()`, and everything above this line -- the matcher's
|
|
distance vectors, `Proposal.total_distance`, the report -- is inches and
|
|
is never converted again. See the seam note in `reinforcecore`.
|
|
|
|
THE GRAIN. The returned distance is quantized to 1/8 in, the finest
|
|
tolerance this tool uses. See `GRAIN_IN` above for why, and for why
|
|
exactness is deliberately left off the grain.
|
|
|
|
THE APPROXIMATION FLOOR. A feature on a piece whose outline could only be
|
|
approximated (see the module docstring) and which touches a panel edge can
|
|
never return 0.0. It returns EXACT_TOL_IN instead, so it ranks immediately
|
|
behind every genuinely exact match and can never be reported as one. That
|
|
is not a fudge factor on the geometry; it is the refusal to claim an exact
|
|
edge match on an edge nobody measured.
|
|
"""
|
|
d = _read(donor_feature)
|
|
t = _read(target_feature)
|
|
if d is None or t is None:
|
|
return None
|
|
if d['kind'] != t['kind']:
|
|
return None
|
|
|
|
if not _holds_centre(d, t['cx'], t['cy']):
|
|
return None
|
|
if not _holds_centre(t, d['cx'], d['cy']):
|
|
return None
|
|
|
|
if d['kind'] != OPENING:
|
|
if d['strip'] is None or t['strip'] is None:
|
|
return None
|
|
if d['strip'] > t['strip'] + EXACT_TOL_FT:
|
|
return None # donor cage will not fit
|
|
elif _through_contradicts(donor_feature, target_feature):
|
|
return None
|
|
|
|
# Computed in feet, because that is what the records are in.
|
|
dist_ft = (abs(d['x0'] - t['x0']) + abs(d['x1'] - t['x1']) +
|
|
abs(d['y0'] - t['y0']) + abs(d['y1'] - t['y1']))
|
|
if dist_ft < EXACT_TOL_FT:
|
|
if _edge_approximate(donor_feature, target_feature):
|
|
return EXACT_TOL_IN # close, but not claimed as exact
|
|
return 0.0
|
|
# The one conversion. Feet below, inches above, and nothing converts twice.
|
|
return _quantize(core.inches(dist_ft))
|
|
|
|
|
|
def _read(feature):
|
|
try:
|
|
x0 = float(feature['x0'])
|
|
x1 = float(feature['x1'])
|
|
y0 = float(feature['y0'])
|
|
y1 = float(feature['y1'])
|
|
kind = feature['kind']
|
|
except (TypeError, ValueError, KeyError, IndexError):
|
|
return None
|
|
strip = _opt(_get(feature, 'strip_ft'))
|
|
return {'kind': kind, 'x0': x0, 'x1': x1, 'y0': y0, 'y1': y1,
|
|
'cx': (x0 + x1) / 2.0, 'cy': (y0 + y1) / 2.0, 'strip': strip}
|
|
|
|
|
|
def _holds_centre(rect, cx, cy):
|
|
return (min(rect['x0'], rect['x1']) <= cx <= max(rect['x0'], rect['x1']) and
|
|
min(rect['y0'], rect['y1']) <= cy <= max(rect['y0'], rect['y1']))
|
|
|
|
|
|
def _through_contradicts(donor_feature, target_feature):
|
|
"""True only when both sides were judged AND they disagree.
|
|
|
|
`None` is could-not-judge. It is not False and is never read as one.
|
|
"""
|
|
dt = _get(donor_feature, 'through')
|
|
tt = _get(target_feature, 'through')
|
|
if dt is None or tt is None:
|
|
return False
|
|
return bool(dt) != bool(tt)
|
|
|
|
|
|
def _edge_approximate(donor_feature, target_feature):
|
|
"""Either side an approximated outline, and the comparison touches an edge."""
|
|
for f in (donor_feature, target_feature):
|
|
if not _get(f, 'outline_approximate'):
|
|
continue
|
|
if _get(f, 'between') == VOID_EDGE or _get(f, 'kind') == OPENING:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _get(record, key):
|
|
try:
|
|
return record.get(key)
|
|
except AttributeError:
|
|
return None
|
|
|
|
|
|
# -------------------------------------------------------- the design number
|
|
#
|
|
# THE HARD RULE. Owner, 2026-09-01: "选择母版的目标,必须得是跟自己的design
|
|
# number一样的panel。我刚在一个WP2.0上面跑了reinforce,给我俩WP1.0的母版,压根
|
|
# 用不了。" 必须 -- so this is a FILTER, not a term in the ranking. A WP1.0
|
|
# donor offered for a WP2.0 target is not a weaker suggestion that a penalty
|
|
# could push down the list; it is an answer the engineer cannot use at all,
|
|
# and a ranking that can be overruled by geometry would put one back on top
|
|
# the moment its openings happened to line up.
|
|
#
|
|
# HOW TWO DESIGN NUMBERS ARE COMPARED: through `specformat.design_key`, which
|
|
# is this codebase's one answer to that question and was already the rule
|
|
# Ticket QC checks a piece against its ticket with (`_check_design_number`).
|
|
# It reduces a packet's page suffixes away -- PNL1, PNL1.0, PNL1.A and PNL1.B1
|
|
# are all the key PNL1 -- which is the owner's established nomenclature
|
|
# ruling: a packet suffix and a `.A` note are not separate designs. It does
|
|
# NOT merge PNL1 with PNL2, which is exactly the distinction he was handed
|
|
# wrongly.
|
|
#
|
|
# WHY THE PATTERN IS A PARAMETER. The reduction is plant knowledge
|
|
# (`nomenclature.design_number_re`), and this module may not import
|
|
# `knowledge` -- it has to run in `tools/reinforce_offline.py` with no model
|
|
# and therefore no knowledge layer. So `suggest_for_piece` resolves the
|
|
# pattern from the job's knowledge and hands it in; offline it is None and
|
|
# `design_key` falls back to its built-in pattern, which is what an unmapped
|
|
# nomenclature has always produced everywhere else in this codebase.
|
|
#
|
|
# NONE IS NOT A DESIGN. `design_family` returns None both for a piece with no
|
|
# design number and for one whose number the pattern cannot read. Two Nones
|
|
# are NOT a match here -- see `_design_reject` -- because "neither of these was
|
|
# read" is not "these are the same".
|
|
|
|
|
|
def design_family(value, pattern=None):
|
|
"""The design family key for one design number, or None if unreadable.
|
|
|
|
Thin, and deliberately so: it exists to name the rule at the call site and
|
|
to keep `specformat` the only implementation of it.
|
|
"""
|
|
return specformat.design_key(value, None, pattern)
|
|
|
|
|
|
def design_pattern(know):
|
|
"""The plant's design-number pattern out of a knowledge tree, or None.
|
|
|
|
The literal key is written out here rather than reached through a
|
|
constant so `tools/knowledge_drift.py` credits it with a reader; see the
|
|
same note in `reinforceindex.master_token`. None -- no knowledge layer, or
|
|
a plant that has not set it -- means `design_key`'s built-in fallback,
|
|
which is what every other reader of this key already does.
|
|
"""
|
|
if not know:
|
|
return None
|
|
from vibe import knowledge
|
|
return knowledge.get(know, 'nomenclature.design_number_re', None)
|
|
|
|
|
|
# ------------------------------------------------------ the elevation affinity
|
|
#
|
|
# THE LAST TIE-BREAK, RULED 2026-09-02. WP425 ranked five donors dead level
|
|
# on every other criterion -- 1 donor, 5/5 covered, 4 exact, 0 reviewed,
|
|
# 0.04 in total distance, all five identical -- and the tie fell through to
|
|
# mark order, so WP133 printed first though WP234 shares WP425's own
|
|
# building elevation: owner, "WP234明明在同一个elevation,为什么排名没有
|
|
# 提前?" Approved as briefed the same day ("同意,就这么做"): elevation is
|
|
# an AFFINITY, never a filter -- a cross-elevation donor with better
|
|
# coverage still wins, exactly the standing correction on donor order this
|
|
# module already carries for the design-number rule. See
|
|
# `reinforcematch`'s ranking notes for where it sits in the comparator.
|
|
#
|
|
# WHAT "SAME ELEVATION" MEANS, settled in three passes the same day. First:
|
|
# a hand-typed parameter (`panel.elevation`) -- wrong, reversed. Second: the
|
|
# owner ruled it PURELY GEOMETRIC ("elevation是纯几何问题"). Third, the
|
|
# scope correction once MC's real model turned up a usable grouping
|
|
# parameter (`Tagged_Sheet`) that not every job has: geometry is the FLOOR,
|
|
# always computed and always correct; a plant-declared grouping key is an
|
|
# OPTIONAL ACCELERATOR checked first where it exists. See
|
|
# `reinforcecore.elevation_match`'s module note for the whole story --
|
|
# this function is just the caller, via `core.elevation_match`, which
|
|
# returns which of the two sources answered so the report can say.
|
|
#
|
|
# UNKNOWN IS NO AFFINITY, NEVER A MATCH, from EITHER source.
|
|
# `core.elevation_match` never treats two could-not-judge values as
|
|
# agreeing -- a target with no signal at all prefers nobody, and a donor
|
|
# with no signal is never preferred even when the target's is known. Same
|
|
# discipline `_design_reject` already applies to design numbers, one level
|
|
# down in consequence: this is a ranking term, not a filter, so getting it
|
|
# wrong here means a worse ORDER, never a lost donor.
|
|
|
|
def _elevation_affinity(target, kept):
|
|
"""(target's own elevation signal, {donor name, ...} sharing it,
|
|
{donor name: source, ...}).
|
|
|
|
`target`'s signal is `{'group': ..., 'band': ...}` -- whatever the
|
|
target itself carries of each, for the report to describe even when
|
|
nothing in the pool matches. `source` is `core.ELEVATION_BY_GROUP` or
|
|
`core.ELEVATION_BY_BAND`, naming which one actually decided that donor.
|
|
|
|
`kept` is `select_donors`'s first return -- the pool AFTER the filter,
|
|
so a donor already rejected for design or thickness is never considered
|
|
for the affinity either; there is nothing to prefer among candidates
|
|
that were never offered.
|
|
"""
|
|
target = target or {}
|
|
target_group = core.elevation_group_key(target.get('elevation_group'))
|
|
target_band = target.get('elevation_band')
|
|
same = set()
|
|
sources = {}
|
|
if target_group is not None or target_band:
|
|
for rec in kept or ():
|
|
source = core.elevation_match(target, rec or {})
|
|
if source is not None:
|
|
donor_name = (rec or {}).get('name')
|
|
same.add(donor_name)
|
|
sources[donor_name] = source
|
|
return {'group': target_group, 'band': target_band}, same, sources
|
|
|
|
|
|
# ---------------------------------------------------------------- the filter
|
|
|
|
def _target_measured_empty(target):
|
|
"""True only when the TARGET was positively measured to have no voids.
|
|
|
|
`features == []` and not `features is None` -- the project's cardinal
|
|
distinction, re-asserted here because this is the one place a FALSE
|
|
positive on it (reading a never-measured target as a featureless one)
|
|
would silently widen a filter instead of narrowing it. See
|
|
`DonorFilter.require_features`.
|
|
"""
|
|
feats = (target or {}).get('features')
|
|
return feats is not None and not feats
|
|
|
|
|
|
# Owner, verbatim (2026-09-01), hit on a real piece: "如果扫描到目标没有
|
|
# opening,那么就找另一块没有opening的板子给target当doner。" -- if the scan
|
|
# finds the target has no opening, find another panel with no opening to be
|
|
# the target's donor.
|
|
# Owner, verbatim (2026-09-01), the very next sentence: "在这个过程当中,很
|
|
# 多其他东西依然是有用的,比如说design number。" -- in this process a lot of
|
|
# other things are still useful, such as design number.
|
|
class DonorFilter(object):
|
|
"""Which indexed pieces may stand in for this target. A named predicate.
|
|
|
|
Every rule is a judgement call, stated so it can be argued with and
|
|
changed in one place rather than buried in a comprehension.
|
|
|
|
prefixes = None (the DECLARED SCOPE decides)
|
|
Which marks may donate. Owner's ruling, 2026-08-30: a splitter for
|
|
wall panels filters to wall panels -- a beam's or a slab's reinforcing
|
|
has nothing to do with a panel's, and on the job this was measured on
|
|
the majority of eligible pieces were exactly that.
|
|
|
|
The ruling has not changed; where it is WRITTEN DOWN has. It used to
|
|
be this default. That made one job's division of labour a constant in
|
|
a shareable repository, which is the same unauthorised exclusion the
|
|
rest of this codebase exists to prevent, one layer further back.
|
|
`suggest_for_piece` now fills this in from `vibe.scope` -- the scope
|
|
the JOB file declares -- and `None` here means no prefix rule at all,
|
|
which is what an undeclared scope correctly produces. Every rejection
|
|
is still counted and named in the report either way.
|
|
|
|
match_thickness = True, thickness_tol_in = 0.01
|
|
The donor panel must be the same thickness as the target. The
|
|
strongest of the geometric rules: thickness sets the number of bar
|
|
layers, the cover top and bottom, and where the strand sits, so an
|
|
8 in cage cannot be transplanted into a 12 in panel however well the
|
|
voids line up. In this model the WP pool is 244 at 8 in and 11 at
|
|
12 in -- two populations that should never mix. High confidence.
|
|
|
|
max_width_diff_ft = None (off)
|
|
Width -- a wall panel's height -- is the ARGUABLE one and it is off by
|
|
default. For: a taller panel carries more longitudinal bars and more
|
|
strand rows, so its sheet is not a template for a short one. Against,
|
|
and this is why it is off: the comparison is now per-ligament, and a
|
|
strip 2 ft off the bottom of a 12 ft panel is detailed like the same
|
|
strip 2 ft off the bottom of a 9 ft panel. Turning it on can only
|
|
remove donors the matcher would have ranked on their merits, against
|
|
the owner's standing correction that a filter must never exclude a
|
|
better-shaped donor. THIS IS THE ONE TO REVIEW. Set feet to enable.
|
|
|
|
require_features = True
|
|
A donor measured to have no voids (`features == []`) has no ligaments
|
|
either and can cover nothing, so it is dropped before the compare
|
|
rather than after: arithmetic, not judgement. A donor that was NEVER
|
|
measured (`features is None`) is dropped for a different reason and
|
|
under a different label -- letting one in would silently remove a
|
|
candidate rather than flag it.
|
|
|
|
THE RULE INVERTS WHEN THE TARGET ITSELF HAS NO VOIDS. Owner, hit on a
|
|
real piece 2026-09-01: "if the scan finds the target has no opening,
|
|
find another panel with no opening to be the target's donor." The reasoning above only holds when the
|
|
TARGET has openings to cover -- a featureless donor genuinely has
|
|
nothing to offer a panel that has something to cover. A target
|
|
measured to have NO openings is the opposite case: a panel with no
|
|
openings IS the right donor for a panel with no openings, and this
|
|
rule was discarding exactly that pool. So `reject` now reads the
|
|
TARGET's own `features` (already on the record it is handed) and
|
|
skips this rejection when the target was MEASURED and came back
|
|
empty (`features == []`, never `features is None` -- see point 4
|
|
below). Every other rule in this filter stays in force: design,
|
|
thickness, prefix and self-exclusion are unaffected, because the
|
|
owner's very next sentence ruled that out explicitly: "in this
|
|
process a lot of other things are still useful, such as design
|
|
number." The ranking among the
|
|
survivors -- and what to do with a donor that DOES have voids when
|
|
the target has none -- is `reinforcebind.shape_search_for`, not this
|
|
filter; a filter decides who is IN the pool, not their order.
|
|
|
|
A target that was never measured (`features is None`) must NOT take
|
|
this path -- that is could-not-judge, not measured-empty. `reject` is
|
|
called with the raw target record regardless of its `target_status`
|
|
(`select_donors` always runs, even though `propose` goes on to refuse
|
|
an unjudged target before using the result), so the check here is
|
|
conservative on purpose: it inverts only on a positive empty-list
|
|
read of the target, never on an absent one.
|
|
|
|
exclude_self = True
|
|
A piece is not its own donor; it would win every comparison exactly
|
|
and tell the engineer nothing.
|
|
|
|
match_design = True, design_re = None
|
|
THE HARD ONE, and the only rule here the owner has stated as
|
|
mandatory (2026-09-01). A donor must carry the SAME DESIGN NUMBER as the target,
|
|
reduced through `design_family` so a packet suffix or a `.A` note does
|
|
not split one design into several. It is not a ranking penalty and it
|
|
may not be turned into one: a WP1.0 donor for a WP2.0 target is
|
|
unusable however well its openings line up.
|
|
|
|
It is on by default and there is no knob on the button to turn it off.
|
|
`match_design=False` exists for one purpose -- letting a test state
|
|
what the ranking does INSIDE one design without having to give every
|
|
fixture a design number -- and switching it off in production would
|
|
reintroduce the exact defect.
|
|
|
|
`design_re` is the plant's `nomenclature.design_number_re`, handed in
|
|
rather than read, because this module may not import `knowledge` (see
|
|
the note above `design_family`). None means the built-in fallback.
|
|
|
|
THREE OUTCOMES, not two. `design` is a judged mismatch. `design_unknown`
|
|
is a donor whose own design number could not be read, and it is a
|
|
REJECTION, never a pass: an unread field that defaulted to matching is
|
|
how this defect got shipped. `design_target_unknown` is the target's
|
|
own number missing, which rejects everybody -- and `propose` refuses
|
|
before it ever gets that far, so the report says it once instead of
|
|
six hundred times.
|
|
"""
|
|
|
|
def __init__(self, prefixes=None, match_thickness=True,
|
|
thickness_tol_in=0.01, max_width_diff_ft=None,
|
|
require_features=True, exclude_self=True,
|
|
match_design=True, design_re=None):
|
|
self.prefixes = tuple(prefixes) if prefixes else None
|
|
self.match_thickness = match_thickness
|
|
self.thickness_tol_in = thickness_tol_in
|
|
self.max_width_diff_ft = max_width_diff_ft
|
|
self.require_features = require_features
|
|
self.exclude_self = exclude_self
|
|
self.match_design = match_design
|
|
self.design_re = design_re
|
|
|
|
def reject(self, donor, target):
|
|
"""None to accept, else a short reason string, so every rejection can
|
|
be counted and named."""
|
|
donor = donor or {}
|
|
target = target or {}
|
|
name = donor.get('name')
|
|
if self.exclude_self and name == target.get('name'):
|
|
return 'self'
|
|
feats = donor.get('features')
|
|
if feats is None:
|
|
return 'unjudged'
|
|
if (self.require_features and not feats and
|
|
not _target_measured_empty(target)):
|
|
return 'no_features'
|
|
if not is_eligible(donor):
|
|
return 'not_eligible'
|
|
if self.prefixes is not None and not _has_prefix(name, self.prefixes):
|
|
return 'prefix'
|
|
# AFTER the scope, before the geometry. After, so another engineer's
|
|
# beams are still reported as out of scope rather than as a design
|
|
# mismatch -- they were never candidates and the pool table should
|
|
# keep saying so. Before, because design is 必须 and thickness is a
|
|
# judgement: when a piece fails both, the harder rule is the one worth
|
|
# printing.
|
|
if self.match_design:
|
|
got = self._design_reject(donor, target)
|
|
if got is not None:
|
|
return got
|
|
if self.match_thickness:
|
|
got = _diff(donor.get('thickness_in'), target.get('thickness_in'))
|
|
if got is None:
|
|
return 'thickness_unknown'
|
|
if got > self.thickness_tol_in:
|
|
return 'thickness'
|
|
if self.max_width_diff_ft is not None:
|
|
got = _diff(donor.get('width_ft'), target.get('width_ft'))
|
|
if got is None:
|
|
return 'width_unknown'
|
|
if got > self.max_width_diff_ft:
|
|
return 'width'
|
|
return None
|
|
|
|
def _design_reject(self, donor, target):
|
|
"""None to accept on design grounds, else which of the three it is.
|
|
|
|
Two unreadable design numbers are NOT a match. `None == None` is true
|
|
in Python and false in precast: it would mean two pieces nobody read
|
|
were declared the same design, which is the whole defect.
|
|
"""
|
|
want = design_family(target.get('design_number'), self.design_re)
|
|
if want is None:
|
|
return 'design_target_unknown'
|
|
got = design_family(donor.get('design_number'), self.design_re)
|
|
if got is None:
|
|
return 'design_unknown'
|
|
if got != want:
|
|
return 'design'
|
|
return None
|
|
|
|
def accepts(self, donor, target):
|
|
return self.reject(donor, target) is None
|
|
|
|
|
|
def is_eligible(record):
|
|
"""The index's own donor rule, re-asserted on a record from any source."""
|
|
from vibe import reinforcecore as core
|
|
return core.is_eligible((record or {}).get('reinforced'),
|
|
(record or {}).get('on_hold'))
|
|
|
|
|
|
def _has_prefix(name, prefixes):
|
|
if not name:
|
|
return False
|
|
for p in prefixes:
|
|
if name.startswith(p):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _diff(a, b):
|
|
if a is None or b is None:
|
|
return None
|
|
try:
|
|
return abs(float(a) - float(b))
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
# ------------------------------------------------ the design-number verdict
|
|
#
|
|
# Five answers, and only one of them lets a suggestion be printed. The four
|
|
# refusals are all "could not judge" or "judged, and the answer is no" -- the
|
|
# project's standing distinction -- and none of them may degrade into offering
|
|
# a donor of another design, even labelled as one. There is no fallback here
|
|
# on purpose: a labelled cross-design suggestion is still the thing the owner
|
|
# could not use, and a label is exactly what a hurried reader skips.
|
|
|
|
# The vocabulary is `reinforcecore`'s, so that `reinforcereport` can name
|
|
# these statuses without importing this module. Re-exported here under the
|
|
# same names because this is where the rule that produces them lives, and a
|
|
# reader of `design_info` should not have to go two files to see them.
|
|
#
|
|
# DESIGN_OK at least one candidate is the same design.
|
|
# DESIGN_TARGET_UNKNOWN the target's own design number is missing or
|
|
# unreadable. Nothing can be filtered against a
|
|
# design nobody knows.
|
|
# DESIGN_INDEX_NOT_RECORDED the index does not carry the field at all.
|
|
# DESIGN_INDEX_UNREAD it carries the field and not one candidate has
|
|
# a value in it: the role is unmapped, or the
|
|
# build ran without a knowledge layer.
|
|
# DESIGN_NONE_MATCH everything was read; there is no donor of this
|
|
# design.
|
|
DESIGN_OK = core.DESIGN_OK
|
|
DESIGN_TARGET_UNKNOWN = core.DESIGN_TARGET_UNKNOWN
|
|
DESIGN_INDEX_NOT_RECORDED = core.DESIGN_INDEX_NOT_RECORDED
|
|
DESIGN_INDEX_UNREAD = core.DESIGN_INDEX_UNREAD
|
|
DESIGN_NONE_MATCH = core.DESIGN_NONE_MATCH
|
|
DESIGN_REFUSALS = core.DESIGN_REFUSALS
|
|
DESIGN_UNREADABLE = core.DESIGN_UNREADABLE
|
|
|
|
# Reasons `DonorFilter.reject` can give BEFORE it ever looks at the design.
|
|
# A piece dropped for one of these was never a candidate for this target, so
|
|
# its design number says nothing about what the pool held -- another
|
|
# engineer's beam is not evidence that this job has no WP2 donor.
|
|
PRE_DESIGN_REASONS = ('self', 'unjudged', 'no_features', 'not_eligible',
|
|
'prefix')
|
|
|
|
|
|
def design_info(target, records, rejected, donor_filter=None):
|
|
"""What the design rule did, as data the report turns into a sentence.
|
|
|
|
`rejected` is `select_donors`' second return. The candidate pool is
|
|
reconstructed from it rather than re-filtered, so this can never disagree
|
|
with the filter that actually ran.
|
|
"""
|
|
donor_filter = donor_filter or DonorFilter()
|
|
pattern = donor_filter.design_re
|
|
raw = (target or {}).get('design_number')
|
|
want = design_family(raw, pattern)
|
|
|
|
dropped_early = set()
|
|
for reason in PRE_DESIGN_REASONS:
|
|
for nm in (rejected or {}).get(reason) or ():
|
|
dropped_early.add(nm)
|
|
|
|
# EVERY NUMBER BELOW IS ABOUT CANDIDATES, not about the whole index, and
|
|
# that is what keeps this rule from claiming findings that belong to
|
|
# somebody else. A pool emptied by the scope, by eligibility or by a piece
|
|
# nobody measured was emptied BEFORE the design test ran; saying "no donor
|
|
# of design WP2 exists" about a pool that never contained a single
|
|
# candidate would blame the design rule for another rule's work, and would
|
|
# bury the real reason under a confident sentence.
|
|
candidates = [rec or {} for rec in (records or [])
|
|
if (rec or {}).get('name') not in dropped_early]
|
|
|
|
seen = {}
|
|
same = []
|
|
unreadable = []
|
|
for rec in candidates:
|
|
key = design_family(rec.get('design_number'), pattern)
|
|
if key is None:
|
|
unreadable.append(rec.get('name'))
|
|
seen[DESIGN_UNREADABLE] = seen.get(DESIGN_UNREADABLE, 0) + 1
|
|
continue
|
|
seen[key] = seen.get(key, 0) + 1
|
|
if want is not None and key == want:
|
|
same.append(rec.get('name'))
|
|
|
|
pool = core.design_status(candidates)
|
|
|
|
if not donor_filter.match_design or not candidates:
|
|
# No candidates: the design rule never ran, and NO_DONORS is the
|
|
# finding. `ran` says so, so a reader of `info` cannot mistake this
|
|
# DESIGN_OK for "the design was checked and is fine".
|
|
status = DESIGN_OK
|
|
elif pool == core.DESIGN_NOT_RECORDED:
|
|
status = DESIGN_INDEX_NOT_RECORDED
|
|
elif pool == core.DESIGN_UNREAD:
|
|
status = DESIGN_INDEX_UNREAD
|
|
elif want is None:
|
|
status = DESIGN_TARGET_UNKNOWN
|
|
elif not same:
|
|
status = DESIGN_NONE_MATCH
|
|
else:
|
|
status = DESIGN_OK
|
|
|
|
return {
|
|
'status': status,
|
|
'refused': status in DESIGN_REFUSALS,
|
|
'enforced': bool(donor_filter.match_design),
|
|
'ran': bool(donor_filter.match_design and candidates),
|
|
'target': raw,
|
|
'target_key': want,
|
|
'index_status': pool,
|
|
# What survived everything the filter asks BEFORE the design, which is
|
|
# the honest denominator for "designs seen".
|
|
'candidates': len(candidates),
|
|
'considered': len(records or []),
|
|
'same_design': sorted(n for n in same if n),
|
|
'unreadable': sorted(n for n in unreadable if n),
|
|
'designs_seen': seen,
|
|
}
|
|
|
|
|
|
def select_donors(records, target, donor_filter=None):
|
|
"""(kept, rejected). `rejected` is {reason: [name, ...]} -- a pool that
|
|
shrank from 690 to 200 without saying why is not a filter."""
|
|
donor_filter = donor_filter or DonorFilter()
|
|
kept, rejected = [], {}
|
|
for rec in records or []:
|
|
why = donor_filter.reject(rec, target)
|
|
if why is None:
|
|
kept.append(rec)
|
|
else:
|
|
rejected.setdefault(why, []).append(rec.get('name'))
|
|
kept.sort(key=lambda r: r.get('name') or '')
|
|
for names in rejected.values():
|
|
names.sort()
|
|
return kept, rejected
|
|
|
|
|
|
# --------------------------------------------------- the featureless target
|
|
#
|
|
# WHAT HAPPENS WHEN THE TARGET HAS NO OPENINGS AT ALL.
|
|
#
|
|
# `DonorFilter.reject` stops excluding a featureless donor once the target is
|
|
# itself featureless (see the note on `require_features`), so the pool
|
|
# `select_donors` returns for that target is no longer empty by construction
|
|
# -- but there is nothing left for `reinforcematch.suggest`'s set-cover search
|
|
# to do with it: `target_features` is `[]`, there is nothing to cover, and
|
|
# zero donors are needed to cover nothing. Set cover is the wrong question
|
|
# here. The right one -- "which of these is the closest TEMPLATE, having
|
|
# nothing to cover?" -- has no ligaments to compare, so it is answered with
|
|
# whatever the filter left standing to compare: design (already forced equal
|
|
# by the hard filter), thickness, length and width.
|
|
#
|
|
# TWO TIERS, NOT A FLAT RANKING. A donor that is ALSO featureless is an exact
|
|
# shape twin as far as this tool can tell -- nothing about its cage was driven
|
|
# by a void the target does not have. A donor that DOES carry openings is a
|
|
# real but weaker candidate: the reinforcing it built around its own voids is
|
|
# irrelevant to this target, but the rest of its cage (the field steel that
|
|
# has nothing to do with an opening) may still be a fine template. Owner's
|
|
# brief left this one to be decided rather than ruling on it; the position
|
|
# taken here is INCLUDE, RANKED BELOW -- dropping them would silently narrow
|
|
# the pool again, in the same shape as the defect this module exists to fix,
|
|
# and "irrelevant to this target" is not the same claim as "useless to this
|
|
# target". They are never allowed to outrank a featureless donor, which is
|
|
# the one thing that would make this worse than saying nothing.
|
|
#
|
|
# THE RANKING ITSELF. Within a tier, ordered by absolute difference from the
|
|
# target on thickness, then length, then width -- thickness first because
|
|
# `DonorFilter`'s own docstring already ranks it "the strongest of the
|
|
# geometric rules"; design is left out because the filter has already forced
|
|
# it equal, so ranking on it again would be decoration. DEGENERACY IS
|
|
# EXPECTED, not a bug to route around: this codebase's own nomenclature
|
|
# convention is that a design number identifies a specific sheet, so two
|
|
# panels of the same design very often share the same length, width and
|
|
# thickness exactly, and the "ranking" is then a coin flip on a difference
|
|
# smaller than anything this tool measures to. `_shape_degenerate` catches
|
|
# that case and the report is told to say "indistinguishable", not to print
|
|
# an order it cannot defend -- the same discipline `covers`' GRAIN applies to
|
|
# distances that are close but not exact, one level up.
|
|
|
|
SHAPE_FEATURELESS = 'featureless'
|
|
SHAPE_HAS_FEATURES = 'has_features'
|
|
|
|
_SHAPE_TIER_RANK = {SHAPE_FEATURELESS: 0, SHAPE_HAS_FEATURES: 1}
|
|
|
|
# The tie-break precision: differences at or under this (in the same units
|
|
# the comparison already uses -- inches for thickness, feet for length and
|
|
# width) are not a finding, they are noise the rest of this tool already
|
|
# refuses to rank on. Reusing `EXACT_TOL_FT`/`EXACT_TOL_IN` rather than a new
|
|
# number keeps this consistent with `covers`' own exactness floor instead of
|
|
# inventing a second opinion about how fine a measurement can be trusted.
|
|
|
|
|
|
def _shape_diff(donor_value, target_value):
|
|
"""Absolute difference, or None when either side is unreadable.
|
|
|
|
None is COULD NOT COMPARE, not "no difference" -- it must sort as the
|
|
worst case, never as a tie for first, or an unmeasured donor would win
|
|
the ranking by default.
|
|
"""
|
|
if donor_value is None or target_value is None:
|
|
return None
|
|
try:
|
|
return abs(float(donor_value) - float(target_value))
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _shape_row(target, rec):
|
|
feats = rec.get('features')
|
|
return {
|
|
'name': rec.get('name'),
|
|
'tier': SHAPE_HAS_FEATURES if feats else SHAPE_FEATURELESS,
|
|
'design_number': rec.get('design_number'),
|
|
'thickness_diff_in': _shape_diff(rec.get('thickness_in'),
|
|
target.get('thickness_in')),
|
|
'length_diff_ft': _shape_diff(rec.get('length_ft'),
|
|
target.get('length_ft')),
|
|
'width_diff_ft': _shape_diff(rec.get('width_ft'),
|
|
target.get('width_ft')),
|
|
'master': rec.get('master'),
|
|
}
|
|
|
|
|
|
def _shape_sort_key(row):
|
|
def key(v):
|
|
return v if v is not None else float('inf')
|
|
return (_SHAPE_TIER_RANK[row['tier']], key(row['thickness_diff_in']),
|
|
key(row['length_diff_ft']), key(row['width_diff_ft']),
|
|
0 if row.get('master') else 1, row['name'] or '')
|
|
|
|
|
|
def _shape_degenerate(rows):
|
|
"""True when the FEATURELESS tier cannot be told apart on shape.
|
|
|
|
Fewer than two rows is not degenerate -- there is nothing to rank between,
|
|
which is a plain single answer, not a tie. Two or more whose thickness,
|
|
length and width all land within this tool's own exactness floor are a
|
|
tie the ranking cannot honestly break, and the report has to say so
|
|
rather than present sort order as if it meant something.
|
|
"""
|
|
if len(rows) < 2:
|
|
return False
|
|
|
|
def rounded(row):
|
|
return tuple(
|
|
(round(row[k], 3) if row[k] is not None else None)
|
|
for k in ('thickness_diff_in', 'length_diff_ft', 'width_diff_ft'))
|
|
|
|
return len(set(rounded(r) for r in rows)) == 1
|
|
|
|
|
|
def shape_search_for(target, kept):
|
|
"""The ranked pool for a target measured to have NO openings.
|
|
|
|
`kept` is `select_donors`'s first return -- the pool AFTER the filter,
|
|
which by now already includes featureless donors (the inversion above)
|
|
alongside any donor that has openings of its own. This just tiers and
|
|
ranks what the filter already decided was eligible; it rejects nobody.
|
|
"""
|
|
rows = [_shape_row(target, rec) for rec in (kept or [])]
|
|
rows.sort(key=_shape_sort_key)
|
|
eligible = [r for r in rows if r['tier'] == SHAPE_FEATURELESS]
|
|
secondary = [r for r in rows if r['tier'] == SHAPE_HAS_FEATURES]
|
|
return {
|
|
'ran': True,
|
|
'considered': len(kept or ()),
|
|
'eligible': eligible,
|
|
'secondary': secondary,
|
|
'degenerate': _shape_degenerate(eligible),
|
|
}
|
|
|
|
|
|
# ------------------------------------------------------------- the whole run
|
|
|
|
def propose(target, records, donor_filter=None, approximate=None,
|
|
include_voids=True,
|
|
max_proposals=match.DEFAULT_MAX_PROPOSALS,
|
|
max_donors=match.DEFAULT_MAX_DONORS):
|
|
"""Filter, build features, match. Returns (suggestion, info). Revit-free.
|
|
|
|
`max_donors` is how many donors ONE proposal may contain -- four, by the
|
|
owner's ruling. `max_proposals` is how many proposals come back. They are
|
|
different axes and are passed through untouched.
|
|
|
|
`target` is an index piece record. `records` is every candidate -- pass
|
|
`reinforcecore.donors(cache)`. `approximate` is the set of piece names
|
|
whose outline could only be approximated (see `outline_audit`).
|
|
|
|
`suggestion` is None when there was nothing to judge, and
|
|
`info['target_status']` says which nothing it was: `unjudged` (never
|
|
measured) or `no_features` (measured, genuinely nothing to reinforce).
|
|
For `no_features`, `info['shape_search']` carries a real search anyway --
|
|
see `shape_search_for` -- because "nothing to cover" is not "nothing to
|
|
say"; the target's own emptiness makes a featureless donor the right
|
|
template, and this is where that answer comes from.
|
|
|
|
IT IS ALSO None WHEN THE DESIGN RULE REFUSES, and `info['design']['status']`
|
|
says which of the four refusals it was. That is not a fifth way of saying
|
|
"no suggestions": a target whose design has no donor in the index gets
|
|
NOTHING rather than the nearest other design, by ruling. See
|
|
`design_info` and `DonorFilter.match_design`.
|
|
"""
|
|
donor_filter = donor_filter or DonorFilter()
|
|
approximate = set(approximate or ())
|
|
kept, rejected = select_donors(records, target, donor_filter)
|
|
design = design_info(target, records, rejected, donor_filter)
|
|
target_elevation, same_elevation, elevation_sources = _elevation_affinity(
|
|
target, kept)
|
|
|
|
target_features = features_for(
|
|
target, (target or {}).get('name') in approximate, include_voids)
|
|
info = {
|
|
'target': (target or {}).get('name'),
|
|
'target_status': _status(target, target_features),
|
|
'target_approximate': (target or {}).get('name') in approximate,
|
|
'target_features': target_features,
|
|
'design': design,
|
|
'considered': len(records or []),
|
|
'donors': len(kept),
|
|
'donor_names': [r.get('name') for r in kept],
|
|
'rejected': dict((k, len(v)) for k, v in rejected.items()),
|
|
'rejected_names': rejected,
|
|
'approximate_in_pool': sorted(
|
|
r.get('name') for r in kept if r.get('name') in approximate),
|
|
# The reviewed templates IN THIS POOL, after the filter. Reported
|
|
# separately from the index-wide count because a MASTER that the
|
|
# thickness filter dropped is not one this answer could have used.
|
|
'master_donors': sorted(r.get('name') for r in kept
|
|
if r.get('master') is True),
|
|
# NOT RUN, not "none found": the plant has not mapped
|
|
# `parameters.member.identity_description`, so no piece in this pool
|
|
# was ever read for the marker. An empty `master_donors` means two
|
|
# completely different things depending on this flag and the report
|
|
# must never collapse them.
|
|
'master_unjudged': bool(kept) and all(r.get('master') is None
|
|
for r in kept),
|
|
# The target's own elevation signal -- `{'group': ..., 'band': ...}`,
|
|
# geometry always present, the plant-declared group only where one
|
|
# is mapped and set (see `reinforcecore.elevation_match`) -- and
|
|
# which of THIS POOL shares it: the LAST ranking tie-break
|
|
# (`reinforcematch`'s `same_elevation` term). Both being falsy means
|
|
# "could not be determined", never "nothing matches"; see
|
|
# `_elevation_affinity`.
|
|
'target_elevation': target_elevation,
|
|
'same_elevation_donors': sorted(same_elevation),
|
|
# Which SOURCE decided each matching donor --
|
|
# `core.ELEVATION_BY_GROUP` ("same sheet") or `core.ELEVATION_BY_BAND`
|
|
# ("same geometry") -- so the report can say why rather than just
|
|
# that. Keyed by donor name; a name absent from `same_elevation_donors`
|
|
# is absent here too.
|
|
'same_elevation_sources': elevation_sources,
|
|
'features_by_donor': {},
|
|
}
|
|
if not target_features:
|
|
# A MEASURED-EMPTY TARGET STILL GETS A SEARCH. `suggest` (the set-cover
|
|
# matcher) has nothing to do with zero target features -- there is
|
|
# nothing to cover -- but that is not the same as nothing to say: the
|
|
# donor pool above already inverted `require_features` for exactly
|
|
# this target, and `shape_search_for` is what turns that pool into an
|
|
# answer. An UNJUDGED target (`features is None`) must not take this
|
|
# path -- `_status` already told them apart, and only the genuinely
|
|
# measured-empty one gets searched.
|
|
if info['target_status'] == 'no_features':
|
|
info['shape_search'] = shape_search_for(target, kept)
|
|
return None, info
|
|
# THE HARD FILTER'S REFUSAL, and it is deliberately here -- after the
|
|
# target has been measured, so the report can still say what the piece is
|
|
# and how current the index was, and before any donor's features are built,
|
|
# so not one cross-design comparison is ever made.
|
|
if design['refused']:
|
|
return None, info
|
|
|
|
donors = []
|
|
for rec in kept:
|
|
feats = features_for(rec, rec.get('name') in approximate,
|
|
include_voids)
|
|
info['features_by_donor'][rec.get('name')] = feats
|
|
donors.append((rec.get('name'), feats or []))
|
|
|
|
info['donor_cap'] = max_donors
|
|
suggestion = match.suggest(target_features, donors, covers,
|
|
max_proposals=max_proposals,
|
|
masters=info['master_donors'],
|
|
same_elevation=same_elevation,
|
|
max_donors=max_donors)
|
|
return suggestion, info
|
|
|
|
|
|
def _status(target, feats):
|
|
if not target:
|
|
return 'no_target'
|
|
if target.get('features') is None:
|
|
return 'unjudged' # never measured -- NOT "has no features"
|
|
if not feats:
|
|
return 'no_features' # measured, genuinely nothing to cover
|
|
return 'ok'
|
|
|
|
|
|
def explain(suggestion, info, proposal=None):
|
|
"""One row per target feature: what covered it, how far off, and whether
|
|
the answer rests on an approximation or on an unjudged penetration."""
|
|
proposal = proposal or (suggestion.best if suggestion else None)
|
|
if proposal is None:
|
|
return []
|
|
feats = info.get('target_features') or []
|
|
by_donor = info.get('features_by_donor') or {}
|
|
# THE FEATURE VOCABULARY, read-only. Deserialised once here off whatever
|
|
# `suggest_from_index` stamped on `info` (the cache's plain registry dict).
|
|
# A target NEVER grows the vocabulary -- `Registry.lookup` is read-only, so
|
|
# a target shape no donor ever produced projects to None, which is the
|
|
# informative "no donor carries this shape" signal (surfaced as
|
|
# uncovered/PARTIAL), not an error. Donor feature-IDs come off
|
|
# `core.feature_ids_of`, the read-only projection over a record's features.
|
|
registry = core.Registry.deserialize(info.get('registry'))
|
|
out = []
|
|
for i in range(len(feats)):
|
|
tf = feats[i]
|
|
row = {
|
|
'target_index': i,
|
|
'kind': tf.get('kind'),
|
|
'between': tf.get('between'),
|
|
'edge': tf.get('edge'),
|
|
'rect': (tf.get('x0'), tf.get('x1'), tf.get('y0'), tf.get('y1')),
|
|
'strip_ft': tf.get('strip_ft'),
|
|
'target_feature_id': registry.lookup(core.canonical_descriptor(tf)),
|
|
'donor': None, 'donor_index': None, 'distance': None,
|
|
'exact': False, 'donor_rect': None, 'donor_master': False,
|
|
'donor_feature_id': None,
|
|
'approximate': bool(tf.get('outline_approximate')),
|
|
'through_unjudged': (tf.get('kind') == OPENING and
|
|
tf.get('through') is None),
|
|
}
|
|
got = proposal.donor_for(i)
|
|
if got is not None:
|
|
key, d_index, dist = got
|
|
donor_feats = by_donor.get(key) or []
|
|
df = donor_feats[d_index]
|
|
donor_ids = core.feature_ids_of({'features': donor_feats}, registry)
|
|
row.update({
|
|
'donor': key, 'donor_index': d_index, 'distance': dist,
|
|
'exact': dist == 0.0,
|
|
'donor_master': key in (proposal.masters or ()),
|
|
'donor_rect': (df.get('x0'), df.get('x1'),
|
|
df.get('y0'), df.get('y1')),
|
|
'donor_feature_id': (donor_ids[d_index]
|
|
if donor_ids is not None and
|
|
d_index < len(donor_ids) else None),
|
|
'approximate': (row['approximate'] or
|
|
bool(df.get('outline_approximate'))),
|
|
'through_unjudged': (row['through_unjudged'] or
|
|
(tf.get('kind') == OPENING and
|
|
df.get('through') is None)),
|
|
})
|
|
out.append(row)
|
|
return out
|
|
|
|
|
|
# ------------------------------------------------------------- the Revit end
|
|
|
|
AUDIT_FILE = 'outline.json'
|
|
# A panel whose solid is this much smaller than its extents box, after its
|
|
# recorded voids are taken out, has an outline the index cannot see. Measured
|
|
# on this model: 242 of 255 WP land under 0.1%, the other 13 sit at 3.8% and
|
|
# up, so anything in the low single digits separates the two populations
|
|
# cleanly. Three percent, not tuned finer than the evidence supports.
|
|
APPROX_PCT = 3.0
|
|
|
|
|
|
def outline_audit(doc, cache=None):
|
|
"""Which pieces have an outline the index's rectangle cannot represent.
|
|
|
|
READ ONLY. Compares each piece's real solid volume against its extents box
|
|
minus its recorded voids. Cached beside the index, because it costs one
|
|
geometry read per piece and changes only when the model does.
|
|
|
|
This is the stand-in for the deferred outline-polygon key on the index
|
|
record; see the module docstring for what the real fix is.
|
|
"""
|
|
import json
|
|
import os
|
|
from vibe import reinforceindex as index
|
|
from vibe import liftexport as L
|
|
|
|
if cache is None:
|
|
cache = index.read(doc)
|
|
by = dict(index.assemblies(doc))
|
|
names = set()
|
|
rows = {}
|
|
for name, rec in (cache.get('pieces') or {}).items():
|
|
feats = rec.get('features')
|
|
if feats is None:
|
|
continue
|
|
Lf, Wf, Tin = (rec.get('length_ft'), rec.get('width_ft'),
|
|
rec.get('thickness_in'))
|
|
asm = by.get(name)
|
|
if asm is None or not Lf or not Wf or not Tin:
|
|
continue
|
|
panel = _panel_of(doc, asm)
|
|
if panel is None:
|
|
continue
|
|
try:
|
|
volume = L.concrete_solid(panel).Volume # cubic FEET
|
|
except Exception:
|
|
continue
|
|
# NOT THE UNIT SEAM. These divisions turn the recorded THICKNESS,
|
|
# which is stored in inches, back into feet so it multiplies with the
|
|
# feet length and width into cubic feet -- the unit Revit reports a
|
|
# solid's volume in. Nothing here is displayed or ranked; the seam
|
|
# that the owner's inch ruling lands on is `covers` alone.
|
|
box = Lf * Wf * (Tin / 12.0)
|
|
voided = 0.0
|
|
for f in feats:
|
|
depth = (Tin / 12.0) if f.get('through') else (
|
|
(Tin - (f.get('concrete_in') or 0.0)) / 12.0)
|
|
voided += (f['w'] or 0.0) * (f['h'] or 0.0) * depth
|
|
pct = 100.0 * (box - voided - volume) / box if box else 0.0
|
|
rows[name] = round(pct, 2)
|
|
if pct > APPROX_PCT:
|
|
names.add(name)
|
|
|
|
path = os.path.join(os.path.dirname(index.index_path(doc)), AUDIT_FILE)
|
|
handle = open(path, 'wb')
|
|
try:
|
|
handle.write(json.dumps(
|
|
{'approximate': sorted(names), 'pct': rows},
|
|
separators=(',', ':'), sort_keys=True).encode('utf-8'))
|
|
finally:
|
|
handle.close()
|
|
return names
|
|
|
|
|
|
def load_audit(doc):
|
|
"""The cached outline audit, or None if it has never been run.
|
|
|
|
None is not an empty set: never audited and audited-and-found-none are
|
|
different answers, and a caller that reads the first as the second is
|
|
claiming every outline is exact when nobody checked.
|
|
"""
|
|
import os
|
|
from vibe import reinforceindex as index
|
|
return load_audit_at(os.path.dirname(index.index_path(doc)))
|
|
|
|
|
|
def load_audit_at(folder):
|
|
"""The same file, addressed by FOLDER instead of by document.
|
|
|
|
This is the offline half of `load_audit`: the audit is a plain JSON file
|
|
that sits beside `index.json`, so reading it needs a path and nothing
|
|
else. `load_audit(doc)` exists only to work out that path, and working it
|
|
out is the one step that needs Revit (`index_path` -> `designspec` ->
|
|
`Autodesk.Revit.DB` at module scope).
|
|
"""
|
|
import json
|
|
import os
|
|
path = os.path.join(folder or '', AUDIT_FILE)
|
|
if not os.path.isfile(path):
|
|
return None
|
|
try:
|
|
handle = open(path, 'rb')
|
|
try:
|
|
data = json.loads(handle.read().decode('utf-8'))
|
|
finally:
|
|
handle.close()
|
|
return set(data.get('approximate') or ())
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _panel_of(doc, asm):
|
|
try:
|
|
member_ids = list(asm.GetMemberIds())
|
|
except Exception:
|
|
return None
|
|
for mid in member_ids:
|
|
member = doc.GetElement(mid)
|
|
if member is None:
|
|
continue
|
|
try:
|
|
cat = member.Category.Name if member.Category else ''
|
|
except Exception:
|
|
cat = ''
|
|
if cat == 'Structural Framing':
|
|
return member
|
|
return None
|
|
|
|
|
|
def suggest_for_piece(doc, name, refresh=False, audit=False,
|
|
donor_filter=None, include_voids=True,
|
|
max_proposals=match.DEFAULT_MAX_PROPOSALS, know=None,
|
|
max_donors=match.DEFAULT_MAX_DONORS):
|
|
"""Everything, for one assembly by name, against the live model. READ ONLY.
|
|
|
|
The target is always measured live rather than taken from the cache: the
|
|
piece being reinforced is usually not a donor, so it is usually not in it.
|
|
|
|
`refresh` brings the index up to date first; `audit` re-runs the outline
|
|
audit. Both default off so the common press is a read.
|
|
"""
|
|
from vibe import reinforceindex as index
|
|
from vibe import reinforcecore as core
|
|
|
|
from vibe import scope as scopelib
|
|
|
|
# The donor pool is bounded by the DECLARED SCOPE, read from the job
|
|
# knowledge rather than written into the filter. An undeclared scope
|
|
# leaves `prefixes` None -- every indexed piece may donate -- and the
|
|
# report says which of the two happened, because a pool of one kind and
|
|
# a pool of everything look identical once the answer is printed.
|
|
the_scope = scopelib.of(know) if know else scopelib.Scope()
|
|
if donor_filter is None:
|
|
donor_filter = DonorFilter(prefixes=the_scope.prefixes or None,
|
|
design_re=design_pattern(know))
|
|
|
|
if refresh:
|
|
cache, report = index.refresh(doc, know=know)
|
|
else:
|
|
cache, report = index.read(doc), None
|
|
|
|
approximate = outline_audit(doc, cache) if audit else load_audit(doc)
|
|
|
|
# THE TARGET IS MEASURED LIVE HERE AND THAT IS THE ONLY REASON THIS
|
|
# FUNCTION NEEDS A DOCUMENT. Since schema 5 the index also holds
|
|
# in-scope non-donors, so the cache usually has a record for it too --
|
|
# but a live measurement is never stale and the model is right there, so
|
|
# in Revit the live one wins. `suggest_from_index` is the same tail with
|
|
# the cached record instead, and is what runs with no Revit at all.
|
|
target = (cache.get('pieces') or {}).get(name)
|
|
for asm_name, asm in index.assemblies(doc):
|
|
if asm_name == name:
|
|
sigs = index.live_signatures(doc, [(name, asm)])
|
|
target = index.extract(doc, name, asm, sigs[name][0], know=know)
|
|
break
|
|
if target is None:
|
|
return None, {'target': name, 'target_status': 'not_in_model'}
|
|
|
|
return suggest_from_index(
|
|
cache, name, approximate=approximate, the_scope=the_scope,
|
|
donor_filter=donor_filter, include_voids=include_voids,
|
|
max_proposals=max_proposals, max_donors=max_donors,
|
|
target=target, refresh_report=report, offline=False)
|
|
|
|
|
|
# ------------------------------------------------- the whole run, NO DOCUMENT
|
|
|
|
def suggest_from_index(cache, name, approximate=None, the_scope=None,
|
|
donor_filter=None, include_voids=True,
|
|
max_proposals=match.DEFAULT_MAX_PROPOSALS,
|
|
max_donors=match.DEFAULT_MAX_DONORS,
|
|
target=None, refresh_report=None, offline=True):
|
|
"""Everything, for one piece, FROM THE SNAPSHOT. No Revit on this path.
|
|
|
|
This is the tail `suggest_for_piece` used to hold inline, lifted out
|
|
whole. It is the same filter -> features -> covers -> set cover -> info
|
|
that the button runs; the only difference is where the target record came
|
|
from. Live, `suggest_for_piece` measures the target through the document
|
|
and passes it in as `target`. Offline, `target` is left None and the
|
|
record is taken out of the index like every donor's.
|
|
|
|
`offline=True` (the default here, and False from the live path) is carried
|
|
on `info` and is what makes a report say, in as many words, that it was
|
|
answered from a snapshot rather than from the model. It changes no
|
|
arithmetic anywhere -- see `reinforcereport.offline_banner`.
|
|
|
|
`the_scope` is a `vibe.scope.Scope`. Offline it comes out of the index
|
|
itself (`reinforceindex.scope_in`), because there is no knowledge layer to
|
|
read without a model.
|
|
|
|
Returns `(suggestion, info)` -- a TUPLE, always, exactly like
|
|
`suggest_for_piece`. A caller that treats the return as a Suggestion gets
|
|
a tuple and no error until much later.
|
|
"""
|
|
from vibe import reinforcecore as core
|
|
from vibe import scope as scopelib
|
|
|
|
the_scope = the_scope if the_scope is not None else scopelib.Scope()
|
|
if donor_filter is None:
|
|
# The design pattern comes OUT OF THE INDEX here, not out of a
|
|
# knowledge layer -- offline there is no model and therefore no
|
|
# knowledge layer, and `reinforceindex.refresh` stamped the pattern
|
|
# into the file for exactly this reader. An index written before that
|
|
# stamp returns None and `design_key` falls back, which is what an
|
|
# unmapped nomenclature has always produced.
|
|
donor_filter = DonorFilter(prefixes=the_scope.prefixes or None,
|
|
design_re=core.design_re_in(cache))
|
|
|
|
if target is None:
|
|
target = (cache.get('pieces') or {}).get(name)
|
|
if target is None:
|
|
# OFFLINE THIS IS NOT "NOT IN THE MODEL". The snapshot simply does not
|
|
# hold this piece -- because it is out of the declared scope, or
|
|
# because the index predates it, or because the index was built by an
|
|
# older donors-only version. The model may well contain it and nobody
|
|
# here can tell. Two different words for two different findings.
|
|
return None, {'target': name, 'offline': offline,
|
|
'target_status': 'not_in_index' if offline
|
|
else 'not_in_model'}
|
|
|
|
suggestion, info = propose(target, core.donors(cache),
|
|
donor_filter=donor_filter,
|
|
approximate=approximate,
|
|
include_voids=include_voids,
|
|
max_proposals=max_proposals,
|
|
max_donors=max_donors)
|
|
info['report'] = refresh_report
|
|
info['audited'] = approximate is not None
|
|
info['offline'] = offline
|
|
info['target_source'] = 'index' if offline else 'model'
|
|
info['built_at'] = (cache or {}).get('built_at')
|
|
# THE FEATURE VOCABULARY, carried as its plain JSON dict (never a live
|
|
# Registry object -- `info` may be handed to layers that serialise it), so
|
|
# `explain()` can project read-only feature-IDs. See `core.Registry`.
|
|
info['registry'] = (cache or {}).get('registry')
|
|
# A COLD DONOR DB IS NOT "JUDGED AND FOUND NOTHING". An index with no pieces
|
|
# is either brand new (no Snapshot has run yet) or was just discarded by a
|
|
# schema bump (`core.load` returns an empty cache with a `_load_error`).
|
|
# Either way NOTHING has been indexed to compare against, which reads as
|
|
# NO_DONORS -- but the fix is "run a Snapshot", not "no donor matches". The
|
|
# report reuses the NO_DONORS headline and swaps in that wording so a cold
|
|
# DB can never render as a silent all-clear.
|
|
info['cold_db'] = not ((cache or {}).get('pieces'))
|
|
info['cold_reason'] = (cache or {}).get('_load_error')
|
|
# What the scope kept out of the donor pool, so the report can say it in
|
|
# the same breath as the rest of the pool arithmetic. `rejected['prefix']`
|
|
# already counts them; this names the rule they were held to.
|
|
rejected_names = (info.get('rejected_names') or {}).get('prefix') or []
|
|
in_scope = the_scope.includes(name)
|
|
info['scope'] = {
|
|
'declared': the_scope.is_declared,
|
|
'label': the_scope.label(),
|
|
'declaration': the_scope.declaration_lines(),
|
|
'line': the_scope.line(rejected_names, unit='indexed donor'),
|
|
'target_in_scope': in_scope,
|
|
# ONLY when the target really is outside it. Passing the name
|
|
# unconditionally made every report carry "JUDGED OUTSIDE THE DECLARED
|
|
# SCOPE, AT YOUR REQUEST" beside `target_in_scope: True` -- verified
|
|
# against a live in-scope run, 2026-08-30. A report that contradicts
|
|
# itself in one dict teaches the reader to skip both lines.
|
|
'target_annotation': the_scope.annotation(
|
|
[] if in_scope else [name], unit='piece'),
|
|
}
|
|
# DONORS ONLY, and that is not a narrowing -- it is what this number has
|
|
# always meant: "indexed pieces that took no part in this search". Since
|
|
# schema 5 the file also holds pieces measured only so they can be
|
|
# targets, and one of those that would not measure never was a candidate.
|
|
# Counting it here would put a warning on every press of a job whose scope
|
|
# is undeclared, where the index widens to every assembly in the model and
|
|
# a beam with no structural-framing member to measure is an ordinary,
|
|
# expected miss rather than a lost donor.
|
|
info['coverage_unjudged'] = sorted(
|
|
r['name'] for r in core.donors(cache, include_unjudged=True)
|
|
if (r or {}).get('features') is None)
|
|
return suggestion, info
|