Files

585 lines
25 KiB
Python

from __future__ import annotations
import math
from pathlib import Path
from src.models import (
AssemblyManifest,
AssemblySnapshot,
ComponentManifest,
ConstraintManifest,
PhysicalRelation,
ReducerInstance,
sha256_json,
)
from src.cad.gear_factory import gear_spec_for_component
from src.cad.simplecad_generator import _component_manifests
from src.cad.simplecad_parts import role_for_component
from src.parameter_solver import load_requirement, solve_parameters
from src.placement import solve_reducer_placements
from src.physical_validators import PHYSICAL_VALIDATOR_REGISTRY
from src.topology import load_template
from src.validators import build_validation_report
ROOT = Path(__file__).resolve().parents[1]
REQ = ROOT / "input" / "requirements" / "reducers" / "simple_2k_h" / "simple_2kh_ratio_7p5.json"
TEMPLATE = ROOT / "src" / "configurations" / "simple_2k_h" / "topology.template.json"
def _instance() -> ReducerInstance:
return solve_parameters(requirement=load_requirement(REQ), template=load_template(TEMPLATE))[0]
def _snapshot(
instance: ReducerInstance,
*,
missing_component: bool = False,
missing_industrial_component: bool = False,
missing_physical_component: str | None = None,
missing_external_mesh: bool = False,
bad_helical_external_hand: bool = False,
planet_phase_offset_deg: float = 0.0,
) -> AssemblySnapshot:
p = instance.parameters
d = instance.derived
z_mid = p.face_width_mm / 2.0
component_ids = ["housing", "ring", "sun", "carrier"] + [
f"planet_{index + 1}" for index in range(p.planet_count)
]
components = {}
for cid in component_ids:
is_planet = cid.startswith("planet_")
if cid == "sun":
gear_type = "external"
tooth_count = p.z_sun
pitch_radius = d.sun_pitch_radius_mm
spec = gear_spec_for_component(instance, "sun")
elif cid == "ring":
gear_type = "internal"
tooth_count = p.z_ring
pitch_radius = d.ring_pitch_radius_mm
spec = gear_spec_for_component(instance, "ring")
elif is_planet:
gear_type = "external"
tooth_count = p.z_planet
pitch_radius = d.planet_pitch_radius_mm
spec = gear_spec_for_component(instance, "planet")
else:
gear_type = None
tooth_count = None
pitch_radius = None
spec = None
helix_hand = spec.helix_hand if spec else None
if bad_helical_external_hand and is_planet:
helix_hand = gear_spec_for_component(instance, "sun").helix_hand
components[cid] = ComponentManifest(
component_id=cid,
formula_node="planet" if is_planet else cid,
role="planet" if is_planet else role_for_component(instance, cid),
kind="gear" if cid in {"sun", "ring"} or is_planet else cid,
gear_type=gear_type,
tooth_count=tooth_count,
tooth_form=p.tooth_form if gear_type else None,
helix_angle_deg=spec.helix_angle_deg if spec else None,
helix_hand=helix_hand,
module_mm=p.module_mm if gear_type else None,
pressure_angle_deg=p.pressure_angle_deg if gear_type else None,
face_width_mm=p.face_width_mm if gear_type else None,
pitch_radius_mm=pitch_radius,
center_xyz_mm=(0.0, 0.0, z_mid),
tags=[f"formula.run_id.{instance.run_id}"],
)
fixed_member = instance.boundary.fixed
constraints = [
ConstraintManifest(
constraint_id=f"housing_fixed_to_{fixed_member}",
relation_type="fixed",
component_a=fixed_member,
component_b="housing",
source="simplecadapi_constraint",
cad_constraint_kind="fixed",
)
]
for member in ["sun", "ring", "carrier"]:
if member == fixed_member:
continue
constraints.append(
ConstraintManifest(
constraint_id=f"{member}_{role_for_component(instance, member)}_revolute",
relation_type="revolute_joint",
component_a=fixed_member,
component_b=member,
source="simplecadapi_constraint",
cad_constraint_kind="revolute",
)
)
identity_axis = {
"x_axis": [1.0, 0.0, 0.0],
"y_axis": [0.0, 1.0, 0.0],
"z_axis": [0.0, 0.0, 1.0],
}
placements = {
"housing": {"origin": [0.0, 0.0, 0.0], **identity_axis},
"ring": {"origin": [0.0, 0.0, 0.0], **identity_axis},
"sun": {"origin": [0.0, 0.0, 0.0], **identity_axis},
"carrier": {"origin": [0.0, 0.0, 0.0], **identity_axis},
}
for index in range(p.planet_count):
cid = f"planet_{index + 1}"
angle = 2.0 * math.pi * index / p.planet_count
phase = math.radians(
180.0
- 180.0 / p.z_planet
+ math.degrees(angle) * (1.0 + p.z_sun / p.z_planet)
+ (planet_phase_offset_deg if index == 0 else 0.0)
)
placements[cid] = {
"origin": [
instance.derived.planet_orbit_radius_mm * math.cos(angle),
instance.derived.planet_orbit_radius_mm * math.sin(angle),
0.0,
],
"x_axis": [math.cos(phase), math.sin(phase), 0.0],
"y_axis": [-math.sin(phase), math.cos(phase), 0.0],
"z_axis": [0.0, 0.0, 1.0],
}
constraints.extend(
[
ConstraintManifest(
constraint_id=f"{cid}_pin_revolute",
relation_type="revolute_joint",
component_a="carrier",
component_b=cid,
source="simplecadapi_constraint",
cad_constraint_kind="revolute",
),
ConstraintManifest(
constraint_id=f"sun_to_{cid}_external_mesh",
relation_type="external_mesh",
component_a="sun",
component_b=cid,
source="simplecadapi_constraint",
cad_constraint_kind="gear",
),
ConstraintManifest(
constraint_id=f"ring_to_{cid}_internal_mesh",
relation_type="internal_mesh",
component_a="ring",
component_b=cid,
source="simplecadapi_constraint",
cad_constraint_kind="belt",
),
]
)
if instance.assembly_topology is not None:
for physical in instance.assembly_topology.components:
if physical.component_id in components:
continue
center = (0.0, 0.0, z_mid)
if physical.component_id.startswith(("planet_pin_", "planet_bearing_", "planet_spacer_")):
index = int(physical.component_id.rsplit("_", 1)[1]) - 1
angle = 2.0 * math.pi * index / p.planet_count
center = (
instance.derived.planet_orbit_radius_mm * math.cos(angle),
instance.derived.planet_orbit_radius_mm * math.sin(angle),
z_mid,
)
elif physical.component_id == "sun_input_key" and instance.industrial_parameters:
center = (
instance.industrial_parameters.input_shaft_diameter_mm / 2.0
+ instance.industrial_parameters.sun_key_height_mm / 2.0
+ instance.industrial_parameters.bearing_seat_clearance_mm,
0.0,
z_mid,
)
details = {}
if physical.kind == "bearing" and instance.industrial_parameters:
params = instance.industrial_parameters
if physical.component_id.startswith("planet_bearing_"):
details = {
"bearing_type": "radial_ball_bearing",
"ball_count": params.planet_bearing_ball_count,
"ball_diameter_mm": params.bearing_ball_diameter_mm,
}
elif physical.component_id.startswith("output_bearing_"):
details = {
"bearing_type": "radial_ball_bearing",
"ball_count": params.output_bearing_ball_count,
"ball_diameter_mm": params.bearing_ball_diameter_mm,
}
elif physical.component_id == "sun_input_key" and instance.industrial_parameters:
details = {
"coupling_type": "parallel_key",
"width_mm": instance.industrial_parameters.sun_key_width_mm,
"height_mm": instance.industrial_parameters.sun_key_height_mm,
"length_mm": instance.industrial_parameters.sun_key_length_mm,
}
components[physical.component_id] = ComponentManifest(
component_id=physical.component_id,
formula_node=physical.formula_ref,
role=physical.role,
kind=physical.kind,
center_xyz_mm=center,
tags=[f"formula.run_id.{instance.run_id}", f"formula.ref.{physical.formula_ref}"],
details=details,
)
placements[physical.component_id] = {"origin": list(center), **identity_axis}
for relation in instance.assembly_topology.relations:
constraints.append(
ConstraintManifest(
constraint_id=relation.relation_id,
relation_type=relation.relation_type,
component_a=relation.source_component_id,
component_b=relation.target_component_id,
source="formula_semantic",
relation_id=relation.relation_id,
)
)
if missing_external_mesh:
constraints = [
constraint
for constraint in constraints
if constraint.constraint_id != "sun_to_planet_1_external_mesh"
]
all_component_ids = list(components)
found_components = component_ids[1:] if missing_component else all_component_ids
if missing_industrial_component:
found_components = [cid for cid in found_components if cid != "planet_bearing_1"]
if missing_physical_component is not None:
found_components = [cid for cid in found_components if cid != missing_physical_component]
manifest = AssemblyManifest(
run_id=instance.run_id,
formula_instance_hash=sha256_json(instance.model_dump(mode="json")),
generated_at="test",
generator="test",
components=components,
constraints=constraints,
exported_files={},
geometry_summary={
"preview_volume": 1.0,
"preview_solids": 1,
"model_graph_nodes": 1,
"internal_ring_phase_correction_deg": 180.0 / p.z_ring if p.z_planet % 2 else 0.0,
"planet_phase_formula": "180 - 180 / z_planet + planet_center_angle_deg * (1 + z_sun / z_planet)",
},
)
return AssemblySnapshot(
run_id=instance.run_id,
formula_instance_hash=manifest.formula_instance_hash,
model_path="/tmp/test.model.json",
manifest_path="/tmp/manifest.json",
build_meta_path=None,
components_from_model=found_components,
constraints_from_model=[],
component_placements=placements,
grounded_components=[fixed_member] if fixed_member else [],
gear_constraints=[
f"sun_to_planet_{index + 1}_external_mesh" for index in range(p.planet_count)
if not (missing_external_mesh and index == 0)
],
internal_mesh_constraints=[
f"ring_to_planet_{index + 1}_internal_mesh" for index in range(p.planet_count)
],
revolute_constraints=[
constraint.constraint_id
for constraint in constraints
if constraint.relation_type == "revolute_joint"
],
fixed_constraints=[f"housing_fixed_to_{fixed_member}"],
manifest=manifest,
build_meta={"run_id": instance.run_id, "preview_volume": 1.0},
model_graph_node_count=1,
exported_files={},
read_at="test",
)
def test_validation_report_fails_on_missing_component() -> None:
instance = _instance()
report = build_validation_report(instance, _snapshot(instance, missing_component=True))
assert not report.overall_passed
assert any(check.code == "all_formula_components_exist" and not check.passed for check in report.checks)
def test_relation_validators_generate_expected_checks() -> None:
instance = _instance()
report = build_validation_report(instance, _snapshot(instance))
relation_checks = [
check
for check in report.checks
if check.code.startswith(("external_mesh.", "internal_mesh.", "revolute_joint.", "fixed.", "coaxial."))
]
assert relation_checks
assert all(check.passed for check in relation_checks)
def test_validation_fails_when_topology_relation_missing_in_snapshot() -> None:
instance = _instance()
report = build_validation_report(instance, _snapshot(instance, missing_external_mesh=True))
assert not report.overall_passed
assert any(
check.code.startswith("external_mesh.")
and check.code.endswith(".cad_constraint")
and not check.passed
for check in report.checks
)
def test_validation_report_fails_on_wrong_ratio() -> None:
instance = _instance()
bad = instance.model_copy(
update={"derived": instance.derived.model_copy(update={"ratio": instance.derived.ratio + 1.0})}
)
report = build_validation_report(bad, _snapshot(instance))
assert not report.overall_passed
assert any(check.code == "ratio_matches_instance" and not check.passed for check in report.checks)
def test_manifest_roles_follow_boundary() -> None:
requirement = load_requirement(ROOT / "input" / "requirements" / "reducers" / "simple_2k_h" / "simple_2kh_boundary_a_spur.json")
instance = solve_parameters(requirement=requirement, template=load_template(TEMPLATE))[0]
components = _component_manifests(instance, placement_plan=solve_reducer_placements(instance))
assert components["ring"].role == "input"
assert components["sun"].role == "fixed"
assert components["carrier"].role == "output"
assert "formula.role.input" in components["ring"].tags
assert "formula.role.fixed" in components["sun"].tags
assert "formula.role.output" in components["carrier"].tags
def test_industrial_validation_requires_complete_carrier_assembly() -> None:
requirement = load_requirement(ROOT / "input" / "requirements" / "reducers" / "simple_2k_h" / "simple_2kh_industrial_ratio_7p5_spur.json")
instance = solve_parameters(requirement=requirement, template=load_template(TEMPLATE))[0]
report = build_validation_report(instance, _snapshot(instance))
codes = {check.code: check for check in report.checks}
assert codes["industrial_parameters_present"].passed
assert codes["assembly_topology_present"].passed
assert codes["industrial_required_components_declared"].passed
assert codes["industrial_planet_bearing_relation_count"].passed
assert codes["industrial_required_relations_declared"].passed
assert any(code.startswith("industrial.bearing_revolute.") for code in codes)
assert any(code.startswith("industrial.keyed.") for code in codes)
assert any(code.startswith("industrial.press_fit.") for code in codes)
assert any(code.startswith("industrial.bolted.") for code in codes)
def test_physical_validator_registry_contains_required_types() -> None:
assert {
"bearing_revolute",
"bearing_support",
"bolted",
"press_fit",
"slip_fit",
"axial_retention",
"clearance",
"rigid",
"coaxial",
"keyed",
} <= set(PHYSICAL_VALIDATOR_REGISTRY)
def test_industrial_relations_generate_per_relation_checks() -> None:
requirement = load_requirement(ROOT / "input" / "requirements" / "reducers" / "simple_2k_h" / "simple_2kh_industrial_ratio_7p5_spur.json")
instance = solve_parameters(requirement=requirement, template=load_template(TEMPLATE))[0]
report = build_validation_report(instance, _snapshot(instance))
relation_ids = {relation.relation_id for relation in instance.assembly_topology.relations}
check_codes = {check.code for check in report.checks}
for relation in instance.assembly_topology.relations:
assert f"industrial.{relation.relation_type}.{relation.relation_id}.component_exists" in check_codes
assert relation_ids
def test_industrial_validation_fails_when_planet_bearing_missing() -> None:
requirement = load_requirement(ROOT / "input" / "requirements" / "reducers" / "simple_2k_h" / "simple_2kh_industrial_ratio_7p5_spur.json")
instance = solve_parameters(requirement=requirement, template=load_template(TEMPLATE))[0]
report = build_validation_report(instance, _snapshot(instance, missing_industrial_component=True))
assert any(
check.code == "industrial_all_physical_components_exist"
and not check.passed
and "planet_bearing_1" in check.actual["missing_from_model"]
for check in report.checks
)
def test_missing_planet_pin_fails_bearing_revolute() -> None:
requirement = load_requirement(ROOT / "input" / "requirements" / "reducers" / "simple_2k_h" / "simple_2kh_industrial_ratio_7p5_spur.json")
instance = solve_parameters(requirement=requirement, template=load_template(TEMPLATE))[0]
report = build_validation_report(instance, _snapshot(instance, missing_physical_component="planet_pin_1"))
assert any(
check.code == "industrial.bearing_revolute.planet_1_bearing_revolute_on_planet_pin_1.component_exists"
and not check.passed
and "planet_pin_1" in check.actual
for check in report.checks
)
def test_missing_output_shaft_fails_rigid_output_relation() -> None:
requirement = load_requirement(ROOT / "input" / "requirements" / "reducers" / "simple_2k_h" / "simple_2kh_industrial_ratio_7p5_spur.json")
instance = solve_parameters(requirement=requirement, template=load_template(TEMPLATE))[0]
report = build_validation_report(instance, _snapshot(instance, missing_physical_component="output_shaft"))
assert any(
check.code == "industrial.rigid.carrier_body_rigid_to_output_shaft.component_exists"
and not check.passed
and "output_shaft" in check.actual
for check in report.checks
)
def test_missing_sun_input_key_fails_keyed_relation() -> None:
requirement = load_requirement(ROOT / "input" / "requirements" / "reducers" / "simple_2k_h" / "simple_2kh_industrial_ratio_7p5_spur.json")
instance = solve_parameters(requirement=requirement, template=load_template(TEMPLATE))[0]
report = build_validation_report(instance, _snapshot(instance, missing_physical_component="sun_input_key"))
assert any(
check.code == "industrial.keyed.sun_input_shaft_keyed_to_sun.component_exists"
and not check.passed
and "sun_input_key" in check.actual
for check in report.checks
)
def test_missing_ring_mount_relation_fails_static() -> None:
requirement = load_requirement(ROOT / "input" / "requirements" / "reducers" / "simple_2k_h" / "simple_2kh_industrial_ratio_7p5_spur.json")
instance = solve_parameters(requirement=requirement, template=load_template(TEMPLATE))[0]
topology = instance.assembly_topology.model_copy(
update={
"relations": [
relation
for relation in instance.assembly_topology.relations
if relation.relation_id != "ring_bolted_to_housing"
]
}
)
bad = instance.model_copy(update={"assembly_topology": topology})
report = build_validation_report(bad, _snapshot(bad))
assert any(
check.code == "industrial_required_relations_declared"
and not check.passed
and "ring_bolted_to_housing" in check.expected
for check in report.checks
)
def test_bad_bearing_diameter_order_fails_slip_fit() -> None:
requirement = load_requirement(ROOT / "input" / "requirements" / "reducers" / "simple_2k_h" / "simple_2kh_industrial_ratio_7p5_spur.json")
instance = solve_parameters(requirement=requirement, template=load_template(TEMPLATE))[0]
bad_params = instance.industrial_parameters.model_copy(
update={"planet_bearing_outer_diameter_mm": 999.0}
)
bad = instance.model_copy(update={"industrial_parameters": bad_params})
report = build_validation_report(bad, _snapshot(bad))
assert any(
check.code == "industrial.slip_fit.planet_bearing_1_slip_fit_in_planet_1.geometry_clearance_or_fit"
and not check.passed
for check in report.checks
)
def test_bad_pin_diameter_fails_press_fit_or_bearing_revolute() -> None:
requirement = load_requirement(ROOT / "input" / "requirements" / "reducers" / "simple_2k_h" / "simple_2kh_industrial_ratio_7p5_spur.json")
instance = solve_parameters(requirement=requirement, template=load_template(TEMPLATE))[0]
bad_params = instance.industrial_parameters.model_copy(
update={"planet_pin_diameter_mm": 999.0}
)
bad = instance.model_copy(update={"industrial_parameters": bad_params})
report = build_validation_report(bad, _snapshot(bad))
assert any(
check.code.endswith(".geometry_clearance_or_fit")
and check.code.startswith(
(
"industrial.press_fit.planet_pin_1",
"industrial.bearing_revolute.planet_1",
)
)
and not check.passed
for check in report.checks
)
def test_unknown_physical_relation_type_fails_cleanly() -> None:
requirement = load_requirement(ROOT / "input" / "requirements" / "reducers" / "simple_2k_h" / "simple_2kh_industrial_ratio_7p5_spur.json")
instance = solve_parameters(requirement=requirement, template=load_template(TEMPLATE))[0]
topology = instance.assembly_topology.model_copy(
update={
"relations": instance.assembly_topology.relations
+ [
PhysicalRelation(
relation_id="mystery_relation",
relation_type="mystery_relation",
source_component_id="sun",
target_component_id="housing",
formula_ref="test.unknown",
)
]
}
)
bad = instance.model_copy(update={"assembly_topology": topology})
report = build_validation_report(bad, _snapshot(bad))
assert any(
check.code == "unsupported_physical_relation_type.mystery_relation"
and not check.passed
for check in report.checks
)
def test_helical_mesh_hand_rules() -> None:
requirement = load_requirement(ROOT / "input" / "requirements" / "reducers" / "simple_2k_h" / "simple_2kh_ratio_3_helical.json")
instance = solve_parameters(requirement=requirement, template=load_template(TEMPLATE))[0]
report = build_validation_report(instance, _snapshot(instance))
mesh_checks = [
check
for check in report.checks
if check.code.startswith(("external_mesh.", "internal_mesh."))
and check.code.endswith(".gear_compatibility")
]
assert mesh_checks
assert all(check.passed for check in mesh_checks)
bad_report = build_validation_report(instance, _snapshot(instance, bad_helical_external_hand=True))
assert any(
check.code.startswith("external_mesh.")
and check.code.endswith(".gear_compatibility")
and not check.passed
for check in bad_report.checks
)
def test_gear_mesh_strengthened_checks_are_generated() -> None:
instance = _instance()
report = build_validation_report(instance, _snapshot(instance))
gear_mesh_checks = [
check
for check in report.checks
if check.code.startswith("gear_mesh.")
]
assert gear_mesh_checks
assert any(check.code.endswith(".tip_root_clearance") for check in gear_mesh_checks)
assert any(check.code.endswith(".backlash_range") for check in gear_mesh_checks)
assert any(check.code.endswith(".placement_phase_policy") for check in gear_mesh_checks)
assert any(check.code.endswith(".internal_ring_half_tooth_phase") for check in gear_mesh_checks)
assert any(check.code.endswith(".motion_sample_residual") for check in gear_mesh_checks)
assert all(check.passed for check in gear_mesh_checks)
def test_gear_mesh_phase_error_fails_validation() -> None:
instance = _instance()
report = build_validation_report(instance, _snapshot(instance, planet_phase_offset_deg=2.0))
assert any(
check.code.startswith("gear_mesh.sun_planet_external_mesh_sun_to_planet_1.")
and check.code.endswith(".placement_phase_policy")
and not check.passed
for check in report.checks
)