Adding fits
This commit is contained in:
1 parent
8185f1f8b3
commit
ab39191af5
19 files changed
+1253
-1
No files matched your search
@@ -0,0 +1,201 @@
|
||||
"""Detect "Possible" fits between AugmentedParts.
|
||||
|
||||
A part fits another part if:
|
||||
- They are different PartTypes
|
||||
- They have two attachment points that:
|
||||
- Have different labels (AttachmentPointName)
|
||||
- either:
|
||||
- Are an ISO or UNC thread that exactly match in type and dimensions
|
||||
- Are a Static Connection paired with a Clamp Connection where the
|
||||
static dimension falls within the range of the clamp connection
|
||||
|
||||
Matches found here are recorded as Fit rows labeled Possible. Exact (manually
|
||||
entered, or promoted from Possible) and None (explicitly marked impossible)
|
||||
labels are user-asserted and are never created or removed by this algorithm.
|
||||
"""
|
||||
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.crud import fit as fit_crud
|
||||
from app.models.attachment_point import AttachmentPoint
|
||||
from app.models.augmented_part import AugmentedPart
|
||||
from app.models.clamp_connection import ClampConnection
|
||||
from app.models.fit import FitLabel
|
||||
from app.models.static_connection import StaticConnection
|
||||
|
||||
MM_PER_INCH = 25.4
|
||||
|
||||
|
||||
def _iso_match(ap_a: AttachmentPoint, ap_b: AttachmentPoint) -> bool:
|
||||
a, b = ap_a.iso_thread, ap_b.iso_thread
|
||||
if a is None or b is None:
|
||||
return False
|
||||
return a.diameter_mm == b.diameter_mm and a.pitch_mm == b.pitch_mm
|
||||
|
||||
|
||||
def _unc_match(ap_a: AttachmentPoint, ap_b: AttachmentPoint) -> bool:
|
||||
a, b = ap_a.unc_thread, ap_b.unc_thread
|
||||
if a is None or b is None:
|
||||
return False
|
||||
return (
|
||||
a.diameter_fraction == b.diameter_fraction
|
||||
and a.tpi == b.tpi
|
||||
and a.series == b.series
|
||||
)
|
||||
|
||||
|
||||
def _clamp_range_mm(clamp: ClampConnection) -> tuple[float | None, float | None]:
|
||||
if clamp.min_mm is not None or clamp.max_mm is not None:
|
||||
return clamp.min_mm, clamp.max_mm
|
||||
min_mm = clamp.min_in * MM_PER_INCH if clamp.min_in is not None else None
|
||||
max_mm = clamp.max_in * MM_PER_INCH if clamp.max_in is not None else None
|
||||
return min_mm, max_mm
|
||||
|
||||
|
||||
def _static_in_clamp_range(static: StaticConnection, clamp: ClampConnection) -> bool:
|
||||
min_mm, max_mm = _clamp_range_mm(clamp)
|
||||
if min_mm is None and max_mm is None:
|
||||
return False
|
||||
if min_mm is not None and static.od_mm < min_mm:
|
||||
return False
|
||||
if max_mm is not None and static.od_mm > max_mm:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _static_clamp_match(ap_a: AttachmentPoint, ap_b: AttachmentPoint) -> bool:
|
||||
if ap_a.static_connection is not None and ap_b.clamp_connection is not None:
|
||||
return _static_in_clamp_range(ap_a.static_connection, ap_b.clamp_connection)
|
||||
if ap_b.static_connection is not None and ap_a.clamp_connection is not None:
|
||||
return _static_in_clamp_range(ap_b.static_connection, ap_a.clamp_connection)
|
||||
return False
|
||||
|
||||
|
||||
def attachment_points_match(ap_a: AttachmentPoint, ap_b: AttachmentPoint) -> bool:
|
||||
"""True if these two attachment points satisfy the fit-matching algorithm."""
|
||||
if ap_a.attachment_point_name_id == ap_b.attachment_point_name_id:
|
||||
return False
|
||||
return (
|
||||
_iso_match(ap_a, ap_b)
|
||||
or _unc_match(ap_a, ap_b)
|
||||
or _static_clamp_match(ap_a, ap_b)
|
||||
)
|
||||
|
||||
|
||||
def parts_could_fit(part_a: AugmentedPart, part_b: AugmentedPart) -> bool:
|
||||
return part_a.id != part_b.id and part_a.part_type_id != part_b.part_type_id
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecomputeResult:
|
||||
created: int = 0
|
||||
removed: int = 0
|
||||
|
||||
def __iadd__(self, other: "RecomputeResult") -> "RecomputeResult":
|
||||
self.created += other.created
|
||||
self.removed += other.removed
|
||||
return self
|
||||
|
||||
|
||||
async def _all_parts_by_id(db: AsyncSession) -> dict[int, AugmentedPart]:
|
||||
result = await db.execute(select(AugmentedPart))
|
||||
return {p.id: p for p in result.scalars().all()}
|
||||
|
||||
|
||||
async def _attachment_points_by_part(
|
||||
db: AsyncSession,
|
||||
) -> dict[int, list[AttachmentPoint]]:
|
||||
result = await db.execute(select(AttachmentPoint))
|
||||
by_part: dict[int, list[AttachmentPoint]] = defaultdict(list)
|
||||
for ap in result.scalars().all():
|
||||
by_part[ap.augmented_part_id].append(ap)
|
||||
return by_part
|
||||
|
||||
|
||||
def _canonical_pair(a_id: int, b_id: int) -> tuple[int, int]:
|
||||
return (a_id, b_id) if a_id < b_id else (b_id, a_id)
|
||||
|
||||
|
||||
async def _reconcile(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
scoped_part_ids: set[int],
|
||||
parts_by_id: dict[int, AugmentedPart],
|
||||
points_by_part: dict[int, list[AttachmentPoint]],
|
||||
) -> RecomputeResult:
|
||||
"""Reconcile Possible fits for the given parts against every other part."""
|
||||
candidate_pairs: set[tuple[int, int]] = set()
|
||||
for part_id in scoped_part_ids:
|
||||
part = parts_by_id.get(part_id)
|
||||
if part is None:
|
||||
continue
|
||||
own_points = points_by_part.get(part_id, [])
|
||||
if not own_points:
|
||||
continue
|
||||
for other_id, other in parts_by_id.items():
|
||||
if not parts_could_fit(part, other):
|
||||
continue
|
||||
for own_ap in own_points:
|
||||
for other_ap in points_by_part.get(other_id, []):
|
||||
if attachment_points_match(own_ap, other_ap):
|
||||
candidate_pairs.add(
|
||||
_canonical_pair(own_ap.id, other_ap.id)
|
||||
)
|
||||
|
||||
result = RecomputeResult()
|
||||
|
||||
existing_fits = []
|
||||
for part_id in scoped_part_ids:
|
||||
existing_fits.extend(await fit_crud.list_for_part(db, part_id))
|
||||
existing_by_pair = {
|
||||
(f.attachment_point_a_id, f.attachment_point_b_id): f for f in existing_fits
|
||||
}
|
||||
|
||||
for pair, existing in existing_by_pair.items():
|
||||
if existing.label == FitLabel.POSSIBLE and pair not in candidate_pairs:
|
||||
await fit_crud.delete(db, existing)
|
||||
result.removed += 1
|
||||
|
||||
for ap_a_id, ap_b_id in candidate_pairs:
|
||||
if (ap_a_id, ap_b_id) in existing_by_pair:
|
||||
continue
|
||||
ap_a = await db.get(AttachmentPoint, ap_a_id)
|
||||
ap_b = await db.get(AttachmentPoint, ap_b_id)
|
||||
await fit_crud.create(db, ap_a=ap_a, ap_b=ap_b, label=FitLabel.POSSIBLE)
|
||||
result.created += 1
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def recompute_for_part(db: AsyncSession, augmented_part_id: int) -> RecomputeResult:
|
||||
"""Reconcile Possible fits involving this part against the current algorithm.
|
||||
|
||||
Creates Possible fits for newly-matching attachment point pairs, and removes
|
||||
previously-algorithmic Possible fits that no longer match. Exact/None labels
|
||||
are user-asserted and are never touched.
|
||||
"""
|
||||
parts_by_id = await _all_parts_by_id(db)
|
||||
if augmented_part_id not in parts_by_id:
|
||||
return RecomputeResult()
|
||||
points_by_part = await _attachment_points_by_part(db)
|
||||
return await _reconcile(
|
||||
db,
|
||||
scoped_part_ids={augmented_part_id},
|
||||
parts_by_id=parts_by_id,
|
||||
points_by_part=points_by_part,
|
||||
)
|
||||
|
||||
|
||||
async def recompute_all(db: AsyncSession) -> RecomputeResult:
|
||||
parts_by_id = await _all_parts_by_id(db)
|
||||
points_by_part = await _attachment_points_by_part(db)
|
||||
return await _reconcile(
|
||||
db,
|
||||
scoped_part_ids=set(parts_by_id),
|
||||
parts_by_id=parts_by_id,
|
||||
points_by_part=points_by_part,
|
||||
)
|
||||
Reference in new issue
Block a user