1589 lines
65 KiB
Python
1589 lines
65 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import math
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from ..cascade_physical import (
|
|
CascadeInterstageDesign,
|
|
compute_cascade_interstage_design,
|
|
stage_carrier_plate_thickness_mm,
|
|
)
|
|
from ..configurations.simple_2k_h_cascade.family import (
|
|
local_role,
|
|
stage_component_id,
|
|
stage_parameter_by_id,
|
|
)
|
|
from ..models import (
|
|
AssemblyManifest,
|
|
ComponentManifest,
|
|
ConstraintManifest,
|
|
ReducerInstance,
|
|
StageDerived,
|
|
StageParameters,
|
|
sha256_json,
|
|
utc_now_iso,
|
|
write_json,
|
|
write_model_json,
|
|
)
|
|
from ..placement import PlacementPlan, solve_reducer_placements
|
|
from .gear_factory import GearSpec, hand_for_component, make_external_gear, make_internal_ring_gear
|
|
from .simplecad_assembly import add_forwarded_connector
|
|
from .simplecad_parts import (
|
|
add_axis_connector,
|
|
internal_ring_phase_correction_degrees,
|
|
make_axis_part,
|
|
make_materials,
|
|
make_z_rotation_placement,
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class StageBuildContext:
|
|
instance: ReducerInstance
|
|
stage: StageParameters
|
|
derived: StageDerived
|
|
z_offset_mm: float
|
|
stage_gap_mm: float
|
|
coupling_axis_z_mm: float | None = None
|
|
|
|
@property
|
|
def z_mid_mm(self) -> float:
|
|
return self.z_offset_mm + self.stage.face_width_mm / 2.0
|
|
|
|
|
|
def _stage_contexts(instance: ReducerInstance) -> list[StageBuildContext]:
|
|
if not instance.parameters.stages or not instance.derived.stages:
|
|
raise RuntimeError("cascade CAD generation requires stage parameters and derived values")
|
|
derived_by_stage = {stage.stage_id: stage for stage in instance.derived.stages}
|
|
stage_gap = float((instance.derived.axial_stack or {}).get("stage_gap_mm", 2.0))
|
|
offsets = (instance.derived.axial_stack or {}).get("stage_offsets_mm", {})
|
|
contexts: list[StageBuildContext] = []
|
|
for index, stage in enumerate(instance.parameters.stages):
|
|
z_offset = float(offsets.get(stage.stage_id, 0.0 if index == 0 else contexts[-1].z_offset_mm + contexts[-1].stage.face_width_mm + stage_gap))
|
|
contexts.append(
|
|
StageBuildContext(
|
|
instance=instance,
|
|
stage=stage,
|
|
derived=derived_by_stage[stage.stage_id],
|
|
z_offset_mm=z_offset,
|
|
stage_gap_mm=stage_gap,
|
|
)
|
|
)
|
|
if len(contexts) == 2 and instance.requirement.stages:
|
|
first_requirement, second_requirement = instance.requirement.stages
|
|
first_output = first_requirement.output
|
|
target_mid = contexts[1].z_mid_mm
|
|
contexts = [
|
|
(
|
|
StageBuildContext(
|
|
instance=contexts[0].instance,
|
|
stage=contexts[0].stage,
|
|
derived=contexts[0].derived,
|
|
z_offset_mm=contexts[0].z_offset_mm,
|
|
stage_gap_mm=contexts[0].stage_gap_mm,
|
|
coupling_axis_z_mm=target_mid - contexts[0].z_offset_mm,
|
|
)
|
|
if first_output
|
|
else contexts[0]
|
|
),
|
|
contexts[1],
|
|
]
|
|
return contexts
|
|
|
|
|
|
def _bore_radius_for_sun(ctx: StageBuildContext) -> float:
|
|
return max(0.8, min(2.2, ctx.derived.sun_pitch_radius_mm * 0.35))
|
|
|
|
|
|
def _pin_radius_for_planet(ctx: StageBuildContext) -> float:
|
|
return max(0.6, min(2.0, ctx.derived.planet_pitch_radius_mm * 0.22))
|
|
|
|
|
|
def _ring_solid(
|
|
scad: Any,
|
|
*,
|
|
outer_radius: float,
|
|
inner_radius: float,
|
|
height: float,
|
|
bottom_z: float,
|
|
) -> Any:
|
|
outer = scad.make_cylinder_rsolid(
|
|
radius=outer_radius,
|
|
height=height,
|
|
bottom_face_center=(0.0, 0.0, bottom_z),
|
|
axis=(0.0, 0.0, 1.0),
|
|
)
|
|
bore = scad.make_cylinder_rsolid(
|
|
radius=inner_radius,
|
|
height=height + 2.0,
|
|
bottom_face_center=(0.0, 0.0, bottom_z - 1.0),
|
|
axis=(0.0, 0.0, 1.0),
|
|
)
|
|
return scad.cut_rsolid(outer, bore, skip_non_intersecting=False)
|
|
|
|
|
|
def _component_role(instance: ReducerInstance, component_id: str) -> str:
|
|
if component_id == instance.boundary.input:
|
|
return "input"
|
|
if component_id == instance.boundary.output:
|
|
return "output"
|
|
if component_id in (instance.boundary.fixed_members or []):
|
|
return "fixed"
|
|
if instance.requirement.stages and len(instance.requirement.stages) == 2:
|
|
first, second = instance.requirement.stages
|
|
if component_id in {
|
|
stage_component_id(first.stage_id, first.output),
|
|
stage_component_id(second.stage_id, second.input),
|
|
}:
|
|
return "coupled"
|
|
if component_id == "housing":
|
|
return "housing"
|
|
if "_planet_" in component_id:
|
|
return "planet"
|
|
return local_role(component_id)
|
|
|
|
|
|
def _apply_tags(scad: Any, *, shape: Any, ctx: StageBuildContext, component_id: str) -> Any:
|
|
role = _component_role(ctx.instance, component_id)
|
|
tagged = scad.apply_tag(shape=shape, tag=f"formula.run_id.{ctx.instance.run_id}")
|
|
tagged = scad.apply_tag(shape=tagged, tag=f"formula.node.{component_id}")
|
|
tagged = scad.apply_tag(shape=tagged, tag=f"formula.stage.{ctx.stage.stage_id}")
|
|
tagged = scad.apply_tag(shape=tagged, tag=f"formula.local_role.{local_role(component_id)}")
|
|
if role in {"input", "fixed", "output", "coupled"}:
|
|
tagged = scad.apply_tag(shape=tagged, tag=f"formula.role.{role}")
|
|
return tagged
|
|
|
|
|
|
def _apply_cascade_tags(
|
|
scad: Any,
|
|
*,
|
|
shape: Any,
|
|
instance: ReducerInstance,
|
|
component_id: str,
|
|
formula_ref: str,
|
|
) -> Any:
|
|
tagged = scad.apply_tag(shape=shape, tag=f"formula.run_id.{instance.run_id}")
|
|
tagged = scad.apply_tag(shape=tagged, tag=f"formula.node.{component_id}")
|
|
tagged = scad.apply_tag(shape=tagged, tag="formula.stage.cascade")
|
|
tagged = scad.apply_tag(shape=tagged, tag=f"formula.ref.{formula_ref}")
|
|
return tagged
|
|
|
|
|
|
def _gear_spec(ctx: StageBuildContext, component: str) -> GearSpec:
|
|
stage = ctx.stage
|
|
local = "planet" if component.startswith("planet") else component
|
|
if local == "sun":
|
|
return GearSpec(
|
|
component="sun",
|
|
n_teeth=stage.z_sun,
|
|
gear_type="external",
|
|
module_mm=stage.module_mm,
|
|
pressure_angle_deg=stage.pressure_angle_deg,
|
|
face_width_mm=stage.face_width_mm,
|
|
addendum_coeff=stage.addendum_coeff,
|
|
clearance_coeff=stage.clearance_coeff,
|
|
backlash_mm=stage.backlash_mm,
|
|
tooth_form=stage.tooth_form,
|
|
helix_angle_deg=stage.helix_angle_deg,
|
|
helix_hand=hand_for_component("sun", base_hand=stage.helix_hand),
|
|
)
|
|
if local == "planet":
|
|
return GearSpec(
|
|
component="planet",
|
|
n_teeth=stage.z_planet,
|
|
gear_type="external",
|
|
module_mm=stage.module_mm,
|
|
pressure_angle_deg=stage.pressure_angle_deg,
|
|
face_width_mm=stage.face_width_mm,
|
|
addendum_coeff=stage.addendum_coeff,
|
|
clearance_coeff=stage.clearance_coeff,
|
|
backlash_mm=stage.backlash_mm,
|
|
tooth_form=stage.tooth_form,
|
|
helix_angle_deg=stage.helix_angle_deg,
|
|
helix_hand=hand_for_component("planet", base_hand=stage.helix_hand),
|
|
)
|
|
if local == "ring":
|
|
return GearSpec(
|
|
component="ring",
|
|
n_teeth=stage.z_ring,
|
|
gear_type="internal",
|
|
module_mm=stage.module_mm,
|
|
pressure_angle_deg=stage.pressure_angle_deg,
|
|
face_width_mm=stage.face_width_mm,
|
|
addendum_coeff=stage.addendum_coeff,
|
|
clearance_coeff=stage.clearance_coeff,
|
|
backlash_mm=stage.backlash_mm,
|
|
tooth_form=stage.tooth_form,
|
|
helix_angle_deg=stage.helix_angle_deg,
|
|
helix_hand=hand_for_component("ring", base_hand=stage.helix_hand),
|
|
ring_rim_thickness_mm=stage.ring_rim_thickness_mm,
|
|
)
|
|
raise ValueError(f"unsupported gear component: {component}")
|
|
|
|
|
|
def _connector_specs(
|
|
ctx: StageBuildContext,
|
|
*,
|
|
component_id: str,
|
|
base_specs: list[tuple[str, tuple[float, float, float], str]],
|
|
) -> tuple[tuple[str, tuple[float, float, float], str], ...]:
|
|
if not ctx.instance.requirement.stages:
|
|
return tuple(base_specs)
|
|
first = ctx.instance.requirement.stages[0]
|
|
if component_id == stage_component_id(first.stage_id, first.output) and ctx.coupling_axis_z_mm is not None:
|
|
base_specs.append(("coupling_axis", (0.0, 0.0, ctx.coupling_axis_z_mm), "Coupling axis to next stage"))
|
|
return tuple(base_specs)
|
|
|
|
|
|
def make_stage_sun(scad: Any, *, ctx: StageBuildContext, material: Any) -> Any:
|
|
stage = ctx.stage
|
|
component_id = stage_component_id(stage.stage_id, "sun")
|
|
gear = make_external_gear(scad, _gear_spec(ctx, "sun"))
|
|
bore = scad.make_cylinder_rsolid(
|
|
radius=_bore_radius_for_sun(ctx),
|
|
height=stage.face_width_mm + 2.0,
|
|
bottom_face_center=(0.0, 0.0, -1.0),
|
|
axis=(0.0, 0.0, 1.0),
|
|
)
|
|
gear = scad.cut_rsolid(gear, bore, skip_non_intersecting=False)
|
|
gear = _apply_tags(scad, shape=gear, ctx=ctx, component_id=component_id)
|
|
return make_axis_part(
|
|
scad,
|
|
part_id=f"{component_id}_gear",
|
|
body=gear,
|
|
name=f"{component_id} {stage.tooth_form} sun gear",
|
|
material=material,
|
|
connector_specs=_connector_specs(
|
|
ctx,
|
|
component_id=component_id,
|
|
base_specs=[("axis", (0.0, 0.0, stage.face_width_mm / 2.0), "Sun axis")],
|
|
),
|
|
)
|
|
|
|
|
|
def make_stage_planet(scad: Any, *, ctx: StageBuildContext, material: Any) -> Any:
|
|
stage = ctx.stage
|
|
gear = make_external_gear(scad, _gear_spec(ctx, "planet"))
|
|
bore = scad.make_cylinder_rsolid(
|
|
radius=_pin_radius_for_planet(ctx) + 0.25,
|
|
height=stage.face_width_mm + 2.0,
|
|
bottom_face_center=(0.0, 0.0, -1.0),
|
|
axis=(0.0, 0.0, 1.0),
|
|
)
|
|
gear = scad.cut_rsolid(gear, bore, skip_non_intersecting=False)
|
|
gear = scad.apply_tag(shape=gear, tag=f"formula.run_id.{ctx.instance.run_id}")
|
|
gear = scad.apply_tag(shape=gear, tag=f"formula.stage.{stage.stage_id}")
|
|
gear = scad.apply_tag(shape=gear, tag=f"formula.node.{stage.stage_id}_planet")
|
|
return make_axis_part(
|
|
scad,
|
|
part_id=f"{stage.stage_id}_planet_gear",
|
|
body=gear,
|
|
name=f"{stage.stage_id} reusable {stage.tooth_form} planet gear",
|
|
material=material,
|
|
connector_specs=(("axis", (0.0, 0.0, stage.face_width_mm / 2.0), "Planet spin axis"),),
|
|
)
|
|
|
|
|
|
def make_stage_ring(scad: Any, *, ctx: StageBuildContext, material: Any) -> Any:
|
|
stage = ctx.stage
|
|
component_id = stage_component_id(stage.stage_id, "ring")
|
|
gear = make_internal_ring_gear(scad, _gear_spec(ctx, "ring"))
|
|
phase = internal_ring_phase_correction_degrees(z_planet=stage.z_planet, z_ring=stage.z_ring)
|
|
if abs(phase) > 1e-12:
|
|
gear = scad.rotate_shape(
|
|
shape=gear,
|
|
angle=phase,
|
|
axis=(0.0, 0.0, 1.0),
|
|
origin=(0.0, 0.0, 0.0),
|
|
)
|
|
gear = _apply_tags(scad, shape=gear, ctx=ctx, component_id=component_id)
|
|
return make_axis_part(
|
|
scad,
|
|
part_id=f"{component_id}_gear",
|
|
body=gear,
|
|
name=f"{component_id} internal {stage.tooth_form} ring gear",
|
|
material=material,
|
|
connector_specs=_connector_specs(
|
|
ctx,
|
|
component_id=component_id,
|
|
base_specs=[("axis", (0.0, 0.0, stage.face_width_mm / 2.0), "Ring axis")],
|
|
),
|
|
)
|
|
|
|
|
|
def _planet_angle_degrees(ctx: StageBuildContext, index: int) -> float:
|
|
return 360.0 * index / ctx.stage.planet_count
|
|
|
|
|
|
def _planet_center_xy(ctx: StageBuildContext, index: int) -> tuple[float, float]:
|
|
angle = math.radians(_planet_angle_degrees(ctx, index))
|
|
radius = ctx.derived.planet_orbit_radius_mm
|
|
return radius * math.cos(angle), radius * math.sin(angle)
|
|
|
|
|
|
def _planet_placement(scad: Any, *, ctx: StageBuildContext, index: int) -> Any:
|
|
x, y = _planet_center_xy(ctx, index)
|
|
center_angle = _planet_angle_degrees(ctx, index)
|
|
phase = (180.0 - 180.0 / ctx.stage.z_planet) + center_angle * (
|
|
1.0 + ctx.stage.z_sun / ctx.stage.z_planet
|
|
)
|
|
return make_z_rotation_placement(
|
|
scad,
|
|
origin=(x, y, ctx.z_offset_mm),
|
|
angle_degrees=phase,
|
|
)
|
|
|
|
|
|
def make_stage_carrier(scad: Any, *, ctx: StageBuildContext, material: Any) -> Any:
|
|
stage = ctx.stage
|
|
component_id = stage_component_id(stage.stage_id, "carrier")
|
|
plate_thickness = stage_carrier_plate_thickness_mm(ctx.stage_gap_mm)
|
|
plate_bottom = stage.face_width_mm + 0.25
|
|
pin_radius = _pin_radius_for_planet(ctx)
|
|
hub_radius = max(_bore_radius_for_sun(ctx) + 2.0, ctx.derived.sun_pitch_radius_mm * 0.75)
|
|
disk_radius = ctx.derived.planet_orbit_radius_mm + pin_radius * 2.5
|
|
|
|
disk = scad.make_cylinder_rsolid(
|
|
radius=disk_radius,
|
|
height=plate_thickness,
|
|
bottom_face_center=(0.0, 0.0, plate_bottom),
|
|
axis=(0.0, 0.0, 1.0),
|
|
)
|
|
hub = scad.make_cylinder_rsolid(
|
|
radius=hub_radius,
|
|
height=plate_thickness,
|
|
bottom_face_center=(0.0, 0.0, plate_bottom),
|
|
axis=(0.0, 0.0, 1.0),
|
|
)
|
|
pins = []
|
|
bosses = []
|
|
for index in range(stage.planet_count):
|
|
x, y = _planet_center_xy(ctx, index)
|
|
bosses.append(
|
|
scad.make_cylinder_rsolid(
|
|
radius=pin_radius * 1.8,
|
|
height=plate_thickness,
|
|
bottom_face_center=(x, y, plate_bottom),
|
|
axis=(0.0, 0.0, 1.0),
|
|
)
|
|
)
|
|
pins.append(
|
|
scad.make_cylinder_rsolid(
|
|
radius=pin_radius,
|
|
height=stage.face_width_mm + 0.25 + plate_thickness,
|
|
bottom_face_center=(x, y, 0.0),
|
|
axis=(0.0, 0.0, 1.0),
|
|
)
|
|
)
|
|
body = scad.union_rsolid([disk, hub, bosses, pins], glue=False)
|
|
center_bore = scad.make_cylinder_rsolid(
|
|
radius=_bore_radius_for_sun(ctx) + 0.35,
|
|
height=plate_thickness + 2.0,
|
|
bottom_face_center=(0.0, 0.0, plate_bottom - 1.0),
|
|
axis=(0.0, 0.0, 1.0),
|
|
)
|
|
body = scad.cut_rsolid(body, center_bore, skip_non_intersecting=False)
|
|
body = _apply_tags(scad, shape=body, ctx=ctx, component_id=component_id)
|
|
|
|
connectors = [
|
|
("axis", (0.0, 0.0, stage.face_width_mm / 2.0), "Carrier axis"),
|
|
]
|
|
connectors.extend(
|
|
(
|
|
f"{ctx.stage.stage_id}_planet_{index + 1}_axis",
|
|
(*_planet_center_xy(ctx, index), stage.face_width_mm / 2.0),
|
|
f"Planet {index + 1} pin axis",
|
|
)
|
|
for index in range(stage.planet_count)
|
|
)
|
|
return make_axis_part(
|
|
scad,
|
|
part_id=f"{component_id}",
|
|
body=body,
|
|
name=f"{component_id} carrier with {stage.planet_count} pins",
|
|
material=material,
|
|
connector_specs=_connector_specs(ctx, component_id=component_id, base_specs=list(connectors)),
|
|
)
|
|
|
|
|
|
def make_cascade_housing(
|
|
scad: Any,
|
|
*,
|
|
contexts: list[StageBuildContext],
|
|
material: Any,
|
|
transmission_design: CascadeInterstageDesign,
|
|
) -> Any:
|
|
connector_specs = [
|
|
(
|
|
f"{ctx.stage.stage_id}_axis",
|
|
(0.0, 0.0, ctx.z_mid_mm),
|
|
f"{ctx.stage.stage_id} housing axis",
|
|
)
|
|
for ctx in contexts
|
|
]
|
|
connector_specs.extend(
|
|
(
|
|
(f"{component_id}_axis", (0.0, 0.0, center_z), f"{component_id} housing seat axis")
|
|
for component_id, center_z in transmission_design.bearing_centers_z_mm.items()
|
|
)
|
|
)
|
|
connector_specs.extend(
|
|
(
|
|
(
|
|
f"{component_id}_axis",
|
|
(0.0, 0.0, center_z),
|
|
f"{component_id} bulkhead axis",
|
|
)
|
|
for component_id, center_z in transmission_design.support_centers_z_mm.items()
|
|
)
|
|
)
|
|
bottom_z = min(ctx.z_offset_mm - 2.2 for ctx in contexts)
|
|
top_z = transmission_design.shaft_top_z_mm + 0.50
|
|
body = _ring_solid(
|
|
scad,
|
|
outer_radius=transmission_design.housing_outer_diameter_mm / 2.0,
|
|
inner_radius=transmission_design.housing_inner_diameter_mm / 2.0,
|
|
height=top_z - bottom_z,
|
|
bottom_z=bottom_z,
|
|
)
|
|
body = scad.apply_tag(shape=body, tag=f"formula.run_id.{contexts[0].instance.run_id}")
|
|
body = scad.apply_tag(shape=body, tag="formula.node.housing")
|
|
body = scad.apply_tag(shape=body, tag="formula.ref.cascade.common_housing")
|
|
return make_axis_part(
|
|
scad,
|
|
part_id="cascade_housing",
|
|
body=body,
|
|
name="Cascade common housing shell with interstage bearing seats",
|
|
material=material,
|
|
connector_specs=tuple(connector_specs),
|
|
)
|
|
|
|
|
|
def make_interstage_shaft(
|
|
scad: Any,
|
|
*,
|
|
instance: ReducerInstance,
|
|
design: CascadeInterstageDesign,
|
|
material: Any,
|
|
) -> Any:
|
|
body = scad.make_cylinder_rsolid(
|
|
radius=design.shaft_diameter_mm / 2.0,
|
|
height=design.shaft_span_mm,
|
|
bottom_face_center=(0.0, 0.0, design.shaft_bottom_z_mm),
|
|
axis=(0.0, 0.0, 1.0),
|
|
)
|
|
body = _apply_cascade_tags(
|
|
scad,
|
|
shape=body,
|
|
instance=instance,
|
|
component_id="interstage_shaft",
|
|
formula_ref="cascade.interstage_shaft",
|
|
)
|
|
connector_specs = [
|
|
("axis", (0.0, 0.0, (design.shaft_bottom_z_mm + design.shaft_top_z_mm) / 2.0), "Interstage shaft axis"),
|
|
("s1_output_axis", (0.0, 0.0, design.s1_output_axis_z_mm), "Stage 1 carrier drive axis"),
|
|
("s2_input_axis", (0.0, 0.0, design.s2_input_axis_z_mm), "Stage 2 sun driven axis"),
|
|
("coupler_axis", (0.0, 0.0, design.coupler_center_z_mm), "Interstage coupler axis"),
|
|
]
|
|
connector_specs.extend(
|
|
(f"{component_id}_axis", (0.0, 0.0, center_z), f"{component_id} inner ring axis")
|
|
for component_id, center_z in design.bearing_centers_z_mm.items()
|
|
)
|
|
return make_axis_part(
|
|
scad,
|
|
part_id="interstage_shaft",
|
|
body=body,
|
|
name="Physical interstage shaft from s1 carrier to s2 sun",
|
|
material=material,
|
|
connector_specs=tuple(connector_specs),
|
|
)
|
|
|
|
|
|
def make_cascade_port_shaft(
|
|
scad: Any,
|
|
*,
|
|
instance: ReducerInstance,
|
|
placement_plan: PlacementPlan,
|
|
component_id: str,
|
|
radius_mm: float,
|
|
material: Any,
|
|
) -> Any:
|
|
pose = placement_plan.require_pose(component_id)
|
|
z0, z1 = pose.details["z_span_mm"]
|
|
height = max(0.1, float(z1) - float(z0))
|
|
body = scad.make_cylinder_rsolid(
|
|
radius=radius_mm,
|
|
height=height,
|
|
bottom_face_center=(0.0, 0.0, 0.0),
|
|
axis=(0.0, 0.0, 1.0),
|
|
)
|
|
body = _apply_cascade_tags(
|
|
scad,
|
|
shape=body,
|
|
instance=instance,
|
|
component_id=component_id,
|
|
formula_ref=f"cascade.{component_id}",
|
|
)
|
|
return make_axis_part(
|
|
scad,
|
|
part_id=component_id,
|
|
body=body,
|
|
name=component_id.replace("_", " ").title(),
|
|
material=material,
|
|
connector_specs=(("axis", (0.0, 0.0, height / 2.0), f"{component_id} axis"),),
|
|
)
|
|
|
|
|
|
def make_interstage_coupler(
|
|
scad: Any,
|
|
*,
|
|
instance: ReducerInstance,
|
|
design: CascadeInterstageDesign,
|
|
material: Any,
|
|
) -> Any:
|
|
body = _ring_solid(
|
|
scad,
|
|
outer_radius=design.coupler_outer_diameter_mm / 2.0,
|
|
inner_radius=design.coupler_inner_diameter_mm / 2.0,
|
|
height=design.coupler_width_mm,
|
|
bottom_z=-design.coupler_width_mm / 2.0,
|
|
)
|
|
body = _apply_cascade_tags(
|
|
scad,
|
|
shape=body,
|
|
instance=instance,
|
|
component_id="interstage_coupler",
|
|
formula_ref="cascade.interstage_coupler",
|
|
)
|
|
return make_axis_part(
|
|
scad,
|
|
part_id="interstage_coupler",
|
|
body=body,
|
|
name="Interstage keyed coupling sleeve",
|
|
material=material,
|
|
connector_specs=(("axis", (0.0, 0.0, 0.0), "Interstage coupler axis"),),
|
|
)
|
|
|
|
|
|
def make_interstage_support(
|
|
scad: Any,
|
|
*,
|
|
instance: ReducerInstance,
|
|
design: CascadeInterstageDesign,
|
|
component_id: str,
|
|
material: Any,
|
|
) -> Any:
|
|
body = _ring_solid(
|
|
scad,
|
|
outer_radius=design.support_outer_diameter_mm / 2.0,
|
|
inner_radius=design.support_inner_diameter_mm / 2.0,
|
|
height=design.support_width_mm,
|
|
bottom_z=-design.support_width_mm / 2.0,
|
|
)
|
|
body = _apply_cascade_tags(
|
|
scad,
|
|
shape=body,
|
|
instance=instance,
|
|
component_id=component_id,
|
|
formula_ref=f"cascade.{component_id}",
|
|
)
|
|
return make_axis_part(
|
|
scad,
|
|
part_id=component_id,
|
|
body=body,
|
|
name=component_id.replace("_", " ").title(),
|
|
material=material,
|
|
connector_specs=(("axis", (0.0, 0.0, 0.0), f"{component_id} axis"),),
|
|
)
|
|
|
|
|
|
def make_cascade_ball_bearing(
|
|
scad: Any,
|
|
*,
|
|
instance: ReducerInstance,
|
|
design: CascadeInterstageDesign,
|
|
component_id: str,
|
|
material: Any,
|
|
) -> Any:
|
|
bore_radius = design.bearing_inner_diameter_mm / 2.0
|
|
outer_radius = design.bearing_outer_diameter_mm / 2.0
|
|
width = design.bearing_width_mm
|
|
ball_radius = design.bearing_ball_diameter_mm / 2.0
|
|
raceway_clearance = max(0.01, design.bearing_ball_diameter_mm * 0.08)
|
|
ball_pitch_radius = (bore_radius + outer_radius) / 2.0
|
|
inner_ring_outer_radius = ball_pitch_radius - ball_radius - raceway_clearance
|
|
outer_ring_inner_radius = ball_pitch_radius + ball_radius + raceway_clearance
|
|
if inner_ring_outer_radius <= bore_radius:
|
|
raise RuntimeError(f"{component_id}: interstage bearing has no inner ring wall")
|
|
if outer_ring_inner_radius >= outer_radius:
|
|
raise RuntimeError(f"{component_id}: interstage bearing has no outer ring wall")
|
|
|
|
assembly = scad.make_assembly_rassembly(
|
|
assembly_id=f"{component_id}_radial_ball_bearing",
|
|
name=f"{component_id.replace('_', ' ').title()} radial ball bearing",
|
|
)
|
|
identity = scad.identity_placement_rplacement()
|
|
|
|
def bearing_part(part_id: str, body: Any, name: str) -> Any:
|
|
body = _apply_cascade_tags(
|
|
scad,
|
|
shape=body,
|
|
instance=instance,
|
|
component_id=component_id,
|
|
formula_ref=f"cascade.{component_id}.{part_id}",
|
|
)
|
|
return make_axis_part(
|
|
scad,
|
|
part_id=part_id,
|
|
body=body,
|
|
name=name,
|
|
material=material,
|
|
connector_specs=(("axis", (0.0, 0.0, 0.0), f"{name} axis"),),
|
|
)
|
|
|
|
outer_ring = _ring_solid(
|
|
scad,
|
|
outer_radius=outer_radius,
|
|
inner_radius=outer_ring_inner_radius,
|
|
height=width,
|
|
bottom_z=-width / 2.0,
|
|
)
|
|
inner_ring = _ring_solid(
|
|
scad,
|
|
outer_radius=inner_ring_outer_radius,
|
|
inner_radius=bore_radius,
|
|
height=width,
|
|
bottom_z=-width / 2.0,
|
|
)
|
|
cage_thickness = min(width * 0.12, max(0.06, design.bearing_ball_diameter_mm * 0.18))
|
|
cage_radius_half_width = max(0.035, ball_radius * 0.30)
|
|
cage_inner_radius = max(bore_radius, ball_pitch_radius - cage_radius_half_width)
|
|
cage_outer_radius = min(outer_radius, ball_pitch_radius + cage_radius_half_width)
|
|
cage_z_offset = min(width * 0.35, ball_radius + raceway_clearance + cage_thickness / 2.0)
|
|
cage_front = _ring_solid(
|
|
scad,
|
|
outer_radius=cage_outer_radius,
|
|
inner_radius=cage_inner_radius,
|
|
height=cage_thickness,
|
|
bottom_z=cage_z_offset - cage_thickness / 2.0,
|
|
)
|
|
cage_rear = _ring_solid(
|
|
scad,
|
|
outer_radius=cage_outer_radius,
|
|
inner_radius=cage_inner_radius,
|
|
height=cage_thickness,
|
|
bottom_z=-cage_z_offset - cage_thickness / 2.0,
|
|
)
|
|
|
|
for subcomponent_id, item in [
|
|
(f"{component_id}_outer_ring", bearing_part(f"{component_id}_outer_ring", outer_ring, f"{component_id} outer ring")),
|
|
(f"{component_id}_inner_ring", bearing_part(f"{component_id}_inner_ring", inner_ring, f"{component_id} inner ring")),
|
|
(f"{component_id}_cage_front", bearing_part(f"{component_id}_cage_front", cage_front, f"{component_id} front cage")),
|
|
(f"{component_id}_cage_rear", bearing_part(f"{component_id}_cage_rear", cage_rear, f"{component_id} rear cage")),
|
|
]:
|
|
assembly = scad.add_component_rassembly(
|
|
assembly=assembly,
|
|
item=item,
|
|
component_id=subcomponent_id,
|
|
placement=identity,
|
|
name=subcomponent_id.replace("_", " ").title(),
|
|
)
|
|
|
|
ball = scad.make_sphere_rsolid(radius=ball_radius, center=(0.0, 0.0, 0.0))
|
|
ball = _apply_cascade_tags(
|
|
scad,
|
|
shape=ball,
|
|
instance=instance,
|
|
component_id=component_id,
|
|
formula_ref=f"cascade.{component_id}.rolling_ball",
|
|
)
|
|
ball_part = scad.make_part_rpart(part_id=f"{component_id}_ball", body=ball, name=f"{component_id} rolling ball")
|
|
ball_part = scad.assign_material_rpart(part=ball_part, material=material)
|
|
|
|
ball_component_ids: list[str] = []
|
|
digits = max(2, len(str(design.bearing_ball_count - 1)))
|
|
for index in range(design.bearing_ball_count):
|
|
angle_degrees = 360.0 * index / design.bearing_ball_count
|
|
angle = math.radians(angle_degrees)
|
|
ball_component_id = f"{component_id}_ball_{index:0{digits}d}"
|
|
ball_component_ids.append(ball_component_id)
|
|
assembly = scad.add_component_rassembly(
|
|
assembly=assembly,
|
|
item=ball_part,
|
|
component_id=ball_component_id,
|
|
placement=scad.make_placement_rplacement(
|
|
origin=(ball_pitch_radius * math.cos(angle), ball_pitch_radius * math.sin(angle), 0.0),
|
|
x_axis=(math.cos(angle), math.sin(angle), 0.0),
|
|
y_axis=(-math.sin(angle), math.cos(angle), 0.0),
|
|
),
|
|
name=f"{component_id} ball {index}",
|
|
)
|
|
|
|
assembly = scad.add_revolute_constraint_rassembly(
|
|
assembly=assembly,
|
|
constraint_id=f"{component_id}_inner_outer_revolute",
|
|
connector_a=scad.make_connector_ref_rconnectorref(f"{component_id}_outer_ring", "axis"),
|
|
connector_b=scad.make_connector_ref_rconnectorref(f"{component_id}_inner_ring", "axis"),
|
|
drive_angle_degrees=None,
|
|
angle_limit=None,
|
|
name=f"{component_id} inner ring spins in outer ring",
|
|
)
|
|
assembly = add_forwarded_connector(
|
|
scad,
|
|
assembly=assembly,
|
|
connector_id="outer_axis",
|
|
source_component_id=f"{component_id}_outer_ring",
|
|
source_connector_id="axis",
|
|
name=f"{component_id} outer ring public axis",
|
|
)
|
|
assembly = add_forwarded_connector(
|
|
scad,
|
|
assembly=assembly,
|
|
connector_id="inner_axis",
|
|
source_component_id=f"{component_id}_inner_ring",
|
|
source_connector_id="axis",
|
|
name=f"{component_id} inner ring public axis",
|
|
)
|
|
if hasattr(assembly, "set_metadata"):
|
|
assembly.set_metadata(
|
|
"planetary_reducer.cascade_interstage_bearing",
|
|
{
|
|
"component_id": component_id,
|
|
"bore_diameter_mm": design.bearing_inner_diameter_mm,
|
|
"outer_diameter_mm": design.bearing_outer_diameter_mm,
|
|
"width_mm": design.bearing_width_mm,
|
|
"ball_diameter_mm": design.bearing_ball_diameter_mm,
|
|
"ball_count": design.bearing_ball_count,
|
|
"ball_component_ids": ball_component_ids,
|
|
},
|
|
)
|
|
return assembly
|
|
|
|
|
|
def _placement_z_angle_degrees(placement: Any) -> float:
|
|
return math.degrees(math.atan2(float(placement.x_axis[1]), float(placement.x_axis[0])))
|
|
|
|
|
|
def _shape_in_world(scad: Any, *, shape: Any, placement: Any) -> Any:
|
|
angle = _placement_z_angle_degrees(placement)
|
|
rotated = scad.rotate_shape(
|
|
shape=shape,
|
|
angle=angle,
|
|
axis=(0.0, 0.0, 1.0),
|
|
origin=(0.0, 0.0, 0.0),
|
|
)
|
|
return scad.translate_shape(rotated, tuple(float(value) for value in placement.origin))
|
|
|
|
|
|
def _export_assembled_component_steps(scad: Any, *, assembly: Any, out_dir: Path) -> dict[str, str]:
|
|
component_dir = out_dir / "component_steps"
|
|
component_dir.mkdir(parents=True, exist_ok=True)
|
|
paths: dict[str, str] = {}
|
|
for component_id in assembly.component_ids():
|
|
component = assembly.get_component(component_id)
|
|
item = component.item
|
|
if hasattr(item, "body"):
|
|
shape = item.body
|
|
elif hasattr(item, "component_ids"):
|
|
shape = scad.make_compound_from_assembly_rcompound(assembly=item)
|
|
else:
|
|
continue
|
|
shape = _shape_in_world(scad, shape=shape, placement=component.placement)
|
|
path = component_dir / f"{component_id}.step"
|
|
scad.export_step(shapes=shape, filename=str(path))
|
|
paths[component_id] = str(path.resolve())
|
|
return paths
|
|
|
|
|
|
def _cref(scad: Any, *, component_id: str, connector_id: str) -> Any:
|
|
return scad.make_connector_ref_rconnectorref(component_id=component_id, connector_id=connector_id)
|
|
|
|
|
|
def _stage_parts(
|
|
scad: Any,
|
|
*,
|
|
contexts: list[StageBuildContext],
|
|
placement_plan: PlacementPlan,
|
|
materials: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
instance = contexts[0].instance
|
|
transmission_design = compute_cascade_interstage_design(instance)
|
|
s1_bore_radius = _bore_radius_for_sun(contexts[0])
|
|
s2_hub_radius = max(_bore_radius_for_sun(contexts[1]) + 1.1, contexts[1].derived.sun_pitch_radius_mm * 0.42)
|
|
parts: dict[str, Any] = {
|
|
"housing": make_cascade_housing(
|
|
scad,
|
|
contexts=contexts,
|
|
material=materials["housing"],
|
|
transmission_design=transmission_design,
|
|
),
|
|
"interstage_shaft": make_interstage_shaft(
|
|
scad,
|
|
instance=instance,
|
|
design=transmission_design,
|
|
material=materials["gear"],
|
|
),
|
|
"interstage_coupler": make_interstage_coupler(
|
|
scad,
|
|
instance=instance,
|
|
design=transmission_design,
|
|
material=materials["carrier"],
|
|
),
|
|
"cascade_input_shaft": make_cascade_port_shaft(
|
|
scad,
|
|
instance=instance,
|
|
placement_plan=placement_plan,
|
|
component_id="cascade_input_shaft",
|
|
radius_mm=max(0.55, min(1.70, s1_bore_radius * 0.72)),
|
|
material=materials["carrier"],
|
|
),
|
|
"cascade_output_shaft": make_cascade_port_shaft(
|
|
scad,
|
|
instance=instance,
|
|
placement_plan=placement_plan,
|
|
component_id="cascade_output_shaft",
|
|
radius_mm=max(0.80, min(2.6, s2_hub_radius * 0.45)),
|
|
material=materials["carrier"],
|
|
),
|
|
}
|
|
for component_id in ["interstage_bearing_1", "interstage_bearing_2"]:
|
|
parts[component_id] = make_cascade_ball_bearing(
|
|
scad,
|
|
instance=instance,
|
|
design=transmission_design,
|
|
component_id=component_id,
|
|
material=materials["gear"],
|
|
)
|
|
for component_id in ["interstage_support_1", "interstage_support_2"]:
|
|
parts[component_id] = make_interstage_support(
|
|
scad,
|
|
instance=instance,
|
|
design=transmission_design,
|
|
component_id=component_id,
|
|
material=materials["housing"],
|
|
)
|
|
for ctx in contexts:
|
|
sid = ctx.stage.stage_id
|
|
parts[stage_component_id(sid, "sun")] = make_stage_sun(scad, ctx=ctx, material=materials["gear"])
|
|
parts[f"{sid}_planet"] = make_stage_planet(scad, ctx=ctx, material=materials["gear"])
|
|
parts[stage_component_id(sid, "ring")] = make_stage_ring(scad, ctx=ctx, material=materials["ring"])
|
|
parts[stage_component_id(sid, "carrier")] = make_stage_carrier(scad, ctx=ctx, material=materials["carrier"])
|
|
return parts
|
|
|
|
|
|
def _build_cascade_assembly(
|
|
scad: Any,
|
|
*,
|
|
instance: ReducerInstance,
|
|
contexts: list[StageBuildContext],
|
|
placement_plan: PlacementPlan,
|
|
parts: dict[str, Any],
|
|
) -> Any:
|
|
transmission_design = compute_cascade_interstage_design(instance)
|
|
assembly = scad.make_assembly_rassembly(
|
|
assembly_id=f"{instance.run_id}_simple_2kh_cascade_{instance.parameters.tooth_form}_reducer",
|
|
name=f"Formula-generated two-stage simple 2K-H {instance.parameters.tooth_form} cascade",
|
|
)
|
|
assembly = scad.add_component_rassembly(
|
|
assembly=assembly,
|
|
item=parts["housing"],
|
|
component_id="housing",
|
|
placement=scad.identity_placement_rplacement(),
|
|
name="Cascade housing/reference",
|
|
)
|
|
assembly = scad.add_component_rassembly(
|
|
assembly=assembly,
|
|
item=parts["interstage_shaft"],
|
|
component_id="interstage_shaft",
|
|
placement=scad.identity_placement_rplacement(),
|
|
name="Interstage shaft from stage 1 carrier to stage 2 sun",
|
|
)
|
|
assembly = scad.add_component_rassembly(
|
|
assembly=assembly,
|
|
item=parts["interstage_coupler"],
|
|
component_id="interstage_coupler",
|
|
placement=make_z_rotation_placement(
|
|
scad,
|
|
origin=(0.0, 0.0, transmission_design.coupler_center_z_mm),
|
|
angle_degrees=0.0,
|
|
),
|
|
name="Interstage coupling sleeve",
|
|
)
|
|
for component_id, center_z in transmission_design.bearing_centers_z_mm.items():
|
|
assembly = scad.add_component_rassembly(
|
|
assembly=assembly,
|
|
item=parts[component_id],
|
|
component_id=component_id,
|
|
placement=make_z_rotation_placement(scad, origin=(0.0, 0.0, center_z), angle_degrees=0.0),
|
|
name=component_id.replace("_", " ").title(),
|
|
)
|
|
for component_id, center_z in transmission_design.support_centers_z_mm.items():
|
|
assembly = scad.add_component_rassembly(
|
|
assembly=assembly,
|
|
item=parts[component_id],
|
|
component_id=component_id,
|
|
placement=make_z_rotation_placement(scad, origin=(0.0, 0.0, center_z), angle_degrees=0.0),
|
|
name=component_id.replace("_", " ").title(),
|
|
)
|
|
for ctx in contexts:
|
|
sid = ctx.stage.stage_id
|
|
for local in ["ring", "sun", "carrier"]:
|
|
cid = stage_component_id(sid, local)
|
|
assembly = scad.add_component_rassembly(
|
|
assembly=assembly,
|
|
item=parts[cid],
|
|
component_id=cid,
|
|
placement=make_z_rotation_placement(scad, origin=(0.0, 0.0, ctx.z_offset_mm), angle_degrees=0.0),
|
|
name=f"{cid} {_component_role(instance, cid)}",
|
|
)
|
|
for index in range(ctx.stage.planet_count):
|
|
cid = f"{sid}_planet_{index + 1}"
|
|
assembly = scad.add_component_rassembly(
|
|
assembly=assembly,
|
|
item=parts[f"{sid}_planet"],
|
|
component_id=cid,
|
|
placement=_planet_placement(scad, ctx=ctx, index=index),
|
|
name=f"{sid} planet gear {index + 1}",
|
|
)
|
|
|
|
for ctx in contexts:
|
|
sid = ctx.stage.stage_id
|
|
stage_requirement = next(stage for stage in instance.requirement.stages or [] if stage.stage_id == sid)
|
|
if stage_requirement.fixed is None:
|
|
continue
|
|
fixed_component = stage_component_id(sid, stage_requirement.fixed)
|
|
assembly = scad.ground_component_rassembly(assembly=assembly, component_id=fixed_component)
|
|
assembly = scad.add_fixed_constraint_rassembly(
|
|
assembly=assembly,
|
|
constraint_id=f"{sid}_{stage_requirement.fixed}_housing_fixed",
|
|
connector_a=_cref(scad, component_id=fixed_component, connector_id="axis"),
|
|
connector_b=_cref(scad, component_id="housing", connector_id=f"{sid}_axis"),
|
|
name=f"{fixed_component} fixed to housing",
|
|
)
|
|
for local in ["sun", "ring", "carrier"]:
|
|
if local == stage_requirement.fixed:
|
|
continue
|
|
component = stage_component_id(sid, local)
|
|
role = _component_role(instance, component)
|
|
assembly = scad.add_revolute_constraint_rassembly(
|
|
assembly=assembly,
|
|
constraint_id=f"{component}_{role}_revolute",
|
|
connector_a=_cref(scad, component_id=fixed_component, connector_id="axis"),
|
|
connector_b=_cref(scad, component_id=component, connector_id="axis"),
|
|
drive_angle_degrees=0.0 if component == instance.boundary.input else None,
|
|
angle_limit=None,
|
|
name=f"{component} {role} revolute",
|
|
)
|
|
|
|
for index in range(ctx.stage.planet_count):
|
|
planet_id = f"{sid}_planet_{index + 1}"
|
|
assembly = scad.add_revolute_constraint_rassembly(
|
|
assembly=assembly,
|
|
constraint_id=f"{planet_id}_pin_revolute",
|
|
connector_a=_cref(scad, component_id=stage_component_id(sid, "carrier"), connector_id=f"{sid}_planet_{index + 1}_axis"),
|
|
connector_b=_cref(scad, component_id=planet_id, connector_id="axis"),
|
|
drive_angle_degrees=None,
|
|
angle_limit=None,
|
|
name=f"{planet_id} pin bearing revolute",
|
|
)
|
|
assembly = scad.add_gear_constraint_rassembly(
|
|
assembly=assembly,
|
|
constraint_id=f"{sid}_sun_to_planet_{index + 1}_external_mesh",
|
|
connector_a=_cref(scad, component_id=stage_component_id(sid, "sun"), connector_id="axis"),
|
|
connector_b=_cref(scad, component_id=planet_id, connector_id="axis"),
|
|
pitch_radius_a=ctx.derived.sun_pitch_radius_mm,
|
|
pitch_radius_b=ctx.derived.planet_pitch_radius_mm,
|
|
phase_offset=None,
|
|
name=f"{sid} sun external mesh to planet {index + 1}",
|
|
)
|
|
assembly = scad.add_belt_constraint_rassembly(
|
|
assembly=assembly,
|
|
constraint_id=f"{sid}_ring_to_planet_{index + 1}_internal_mesh",
|
|
connector_a=_cref(scad, component_id=stage_component_id(sid, "ring"), connector_id="axis"),
|
|
connector_b=_cref(scad, component_id=planet_id, connector_id="axis"),
|
|
pulley_radius_a=ctx.derived.ring_pitch_radius_mm,
|
|
pulley_radius_b=ctx.derived.planet_pitch_radius_mm,
|
|
phase_offset=None,
|
|
name=f"{sid} ring internal mesh to planet {index + 1}",
|
|
)
|
|
|
|
if instance.requirement.stages and len(instance.requirement.stages) == 2:
|
|
first, second = instance.requirement.stages
|
|
source = stage_component_id(first.stage_id, first.output)
|
|
target = stage_component_id(second.stage_id, second.input)
|
|
assembly = scad.add_fixed_constraint_rassembly(
|
|
assembly=assembly,
|
|
constraint_id=f"{source}_rigid_to_interstage_shaft",
|
|
connector_a=_cref(scad, component_id=source, connector_id="coupling_axis"),
|
|
connector_b=_cref(scad, component_id="interstage_shaft", connector_id="s1_output_axis"),
|
|
name=f"{source} drives interstage shaft",
|
|
)
|
|
assembly = scad.add_fixed_constraint_rassembly(
|
|
assembly=assembly,
|
|
constraint_id=f"interstage_shaft_rigid_to_{target}",
|
|
connector_a=_cref(scad, component_id="interstage_shaft", connector_id="s2_input_axis"),
|
|
connector_b=_cref(scad, component_id=target, connector_id="axis"),
|
|
name=f"Interstage shaft drives {target}",
|
|
)
|
|
assembly = scad.add_fixed_constraint_rassembly(
|
|
assembly=assembly,
|
|
constraint_id="interstage_coupler_fixed_to_interstage_shaft",
|
|
connector_a=_cref(scad, component_id="interstage_shaft", connector_id="coupler_axis"),
|
|
connector_b=_cref(scad, component_id="interstage_coupler", connector_id="axis"),
|
|
name="Interstage coupling sleeve fixed to shaft",
|
|
)
|
|
for component_id in ["interstage_bearing_1", "interstage_bearing_2"]:
|
|
assembly = scad.add_fixed_constraint_rassembly(
|
|
assembly=assembly,
|
|
constraint_id=f"{component_id}_outer_fixed_to_housing",
|
|
connector_a=_cref(scad, component_id="housing", connector_id=f"{component_id}_axis"),
|
|
connector_b=_cref(scad, component_id=component_id, connector_id="outer_axis"),
|
|
name=f"{component_id} outer ring supported by housing",
|
|
)
|
|
for component_id in ["interstage_support_1", "interstage_support_2"]:
|
|
assembly = scad.add_fixed_constraint_rassembly(
|
|
assembly=assembly,
|
|
constraint_id=f"{component_id}_bolted_to_housing",
|
|
connector_a=_cref(scad, component_id="housing", connector_id=f"{component_id}_axis"),
|
|
connector_b=_cref(scad, component_id=component_id, connector_id="axis"),
|
|
name=f"{component_id} bolted to common housing",
|
|
)
|
|
solved = scad.solve_assembly_constraints_rassembly(assembly=assembly, strict=True)
|
|
for component_id in ["cascade_input_shaft", "cascade_output_shaft"]:
|
|
pose = placement_plan.require_pose(component_id)
|
|
z0 = float(pose.details["z_span_mm"][0])
|
|
solved = scad.add_component_rassembly(
|
|
assembly=solved,
|
|
item=parts[component_id],
|
|
component_id=component_id,
|
|
placement=make_z_rotation_placement(scad, origin=(0.0, 0.0, z0), angle_degrees=0.0),
|
|
name=component_id.replace("_", " ").title(),
|
|
)
|
|
return solved
|
|
|
|
|
|
def _component_manifests(
|
|
instance: ReducerInstance,
|
|
contexts: list[StageBuildContext],
|
|
*,
|
|
placement_plan: PlacementPlan,
|
|
component_step_paths: dict[str, str] | None = None,
|
|
) -> dict[str, ComponentManifest]:
|
|
component_step_paths = component_step_paths or {}
|
|
transmission_design = compute_cascade_interstage_design(instance)
|
|
components: dict[str, ComponentManifest] = {
|
|
"housing": ComponentManifest(
|
|
component_id="housing",
|
|
formula_node="housing",
|
|
local_role="housing",
|
|
role="housing",
|
|
kind="housing",
|
|
center_xyz_mm=placement_plan.require_pose("housing").center_xyz_mm,
|
|
axis_xyz=placement_plan.require_pose("housing").axis_xyz,
|
|
tags=[f"formula.run_id.{instance.run_id}", "formula.node.housing"],
|
|
assembled_step_path=component_step_paths.get("housing"),
|
|
details={
|
|
"housing_inner_diameter_mm": transmission_design.housing_inner_diameter_mm,
|
|
"housing_outer_diameter_mm": transmission_design.housing_outer_diameter_mm,
|
|
"supports_interstage_bearings": ["interstage_bearing_1", "interstage_bearing_2"],
|
|
},
|
|
)
|
|
}
|
|
components["interstage_shaft"] = ComponentManifest(
|
|
component_id="interstage_shaft",
|
|
formula_node="cascade.interstage_shaft",
|
|
stage_id="cascade",
|
|
local_role="interstage_shaft",
|
|
role="interstage_power_transfer",
|
|
kind="shaft",
|
|
center_xyz_mm=placement_plan.require_pose("interstage_shaft").center_xyz_mm,
|
|
axis_xyz=placement_plan.require_pose("interstage_shaft").axis_xyz,
|
|
tags=[
|
|
f"formula.run_id.{instance.run_id}",
|
|
"formula.node.interstage_shaft",
|
|
"formula.stage.cascade",
|
|
"formula.role.coupled",
|
|
],
|
|
assembled_step_path=component_step_paths.get("interstage_shaft"),
|
|
details={
|
|
"shaft_diameter_mm": transmission_design.shaft_diameter_mm,
|
|
"shaft_bottom_z_mm": transmission_design.shaft_bottom_z_mm,
|
|
"shaft_top_z_mm": transmission_design.shaft_top_z_mm,
|
|
"driven_by": transmission_design.source_component_id,
|
|
"drives": transmission_design.target_component_id,
|
|
},
|
|
)
|
|
for component_id, role, connects in [
|
|
("cascade_input_shaft", "input", [instance.boundary.input]),
|
|
("cascade_output_shaft", "output", [instance.boundary.output]),
|
|
]:
|
|
pose = placement_plan.require_pose(component_id)
|
|
z0, z1 = pose.details["z_span_mm"]
|
|
components[component_id] = ComponentManifest(
|
|
component_id=component_id,
|
|
formula_node=f"cascade.{component_id}",
|
|
stage_id="cascade",
|
|
local_role=component_id,
|
|
role=role,
|
|
kind="shaft",
|
|
center_xyz_mm=pose.center_xyz_mm,
|
|
axis_xyz=pose.axis_xyz,
|
|
tags=[
|
|
f"formula.run_id.{instance.run_id}",
|
|
f"formula.node.{component_id}",
|
|
"formula.stage.cascade",
|
|
f"formula.role.{role}",
|
|
],
|
|
assembled_step_path=component_step_paths.get(component_id),
|
|
details={
|
|
"shaft_bottom_z_mm": z0,
|
|
"shaft_top_z_mm": z1,
|
|
"connects": connects,
|
|
},
|
|
)
|
|
components["interstage_coupler"] = ComponentManifest(
|
|
component_id="interstage_coupler",
|
|
formula_node="cascade.interstage_coupler",
|
|
stage_id="cascade",
|
|
local_role="interstage_coupler",
|
|
role="interstage_coupling",
|
|
kind="coupling",
|
|
center_xyz_mm=placement_plan.require_pose("interstage_coupler").center_xyz_mm,
|
|
axis_xyz=placement_plan.require_pose("interstage_coupler").axis_xyz,
|
|
tags=[
|
|
f"formula.run_id.{instance.run_id}",
|
|
"formula.node.interstage_coupler",
|
|
"formula.stage.cascade",
|
|
],
|
|
assembled_step_path=component_step_paths.get("interstage_coupler"),
|
|
details={
|
|
"coupling_type": "keyed_sleeve_semantic",
|
|
"inner_diameter_mm": transmission_design.coupler_inner_diameter_mm,
|
|
"outer_diameter_mm": transmission_design.coupler_outer_diameter_mm,
|
|
"width_mm": transmission_design.coupler_width_mm,
|
|
"center_z_mm": transmission_design.coupler_center_z_mm,
|
|
},
|
|
)
|
|
for component_id, center_z in transmission_design.bearing_centers_z_mm.items():
|
|
components[component_id] = ComponentManifest(
|
|
component_id=component_id,
|
|
formula_node=f"cascade.{component_id}",
|
|
stage_id="cascade",
|
|
local_role="interstage_bearing",
|
|
role="interstage_shaft_support",
|
|
kind="bearing",
|
|
center_xyz_mm=placement_plan.require_pose(component_id).center_xyz_mm,
|
|
axis_xyz=placement_plan.require_pose(component_id).axis_xyz,
|
|
tags=[
|
|
f"formula.run_id.{instance.run_id}",
|
|
f"formula.node.{component_id}",
|
|
"formula.stage.cascade",
|
|
],
|
|
assembled_step_path=component_step_paths.get(component_id),
|
|
details={
|
|
"bearing_type": "radial_ball_bearing",
|
|
"standard_series": "parametric_deep_groove",
|
|
"inner_diameter_mm": transmission_design.bearing_inner_diameter_mm,
|
|
"outer_diameter_mm": transmission_design.bearing_outer_diameter_mm,
|
|
"width_mm": transmission_design.bearing_width_mm,
|
|
"ball_diameter_mm": transmission_design.bearing_ball_diameter_mm,
|
|
"ball_count": transmission_design.bearing_ball_count,
|
|
"center_z_mm": center_z,
|
|
"supported_shaft": "interstage_shaft",
|
|
},
|
|
)
|
|
for component_id, center_z in transmission_design.support_centers_z_mm.items():
|
|
components[component_id] = ComponentManifest(
|
|
component_id=component_id,
|
|
formula_node=f"cascade.{component_id}",
|
|
stage_id="cascade",
|
|
local_role="interstage_support",
|
|
role="interstage_bearing_support",
|
|
kind="support",
|
|
center_xyz_mm=placement_plan.require_pose(component_id).center_xyz_mm,
|
|
axis_xyz=placement_plan.require_pose(component_id).axis_xyz,
|
|
tags=[
|
|
f"formula.run_id.{instance.run_id}",
|
|
f"formula.node.{component_id}",
|
|
"formula.stage.cascade",
|
|
],
|
|
assembled_step_path=component_step_paths.get(component_id),
|
|
details={
|
|
"inner_diameter_mm": transmission_design.support_inner_diameter_mm,
|
|
"outer_diameter_mm": transmission_design.support_outer_diameter_mm,
|
|
"width_mm": transmission_design.support_width_mm,
|
|
"center_z_mm": center_z,
|
|
"bolted_to": "housing",
|
|
},
|
|
)
|
|
for ctx in contexts:
|
|
sid = ctx.stage.stage_id
|
|
for local in ["ring", "sun", "carrier"]:
|
|
cid = stage_component_id(sid, local)
|
|
spec = _gear_spec(ctx, local) if local in {"ring", "sun"} else None
|
|
pitch = {
|
|
"ring": ctx.derived.ring_pitch_radius_mm,
|
|
"sun": ctx.derived.sun_pitch_radius_mm,
|
|
}.get(local)
|
|
components[cid] = ComponentManifest(
|
|
component_id=cid,
|
|
formula_node=cid,
|
|
stage_id=sid,
|
|
local_role=local,
|
|
role=_component_role(instance, cid),
|
|
kind="carrier" if local == "carrier" else "gear",
|
|
gear_type="internal" if local == "ring" else ("external" if local == "sun" else None),
|
|
tooth_count=ctx.stage.z_ring if local == "ring" else (ctx.stage.z_sun if local == "sun" else None),
|
|
tooth_form=ctx.stage.tooth_form if local in {"ring", "sun"} else None,
|
|
helix_angle_deg=spec.helix_angle_deg if spec else None,
|
|
helix_hand=spec.helix_hand if spec else None,
|
|
module_mm=ctx.stage.module_mm if local in {"ring", "sun"} else None,
|
|
pressure_angle_deg=ctx.stage.pressure_angle_deg if local in {"ring", "sun"} else None,
|
|
face_width_mm=ctx.stage.face_width_mm if local in {"ring", "sun"} else None,
|
|
pitch_radius_mm=pitch,
|
|
center_xyz_mm=placement_plan.require_pose(cid).center_xyz_mm,
|
|
axis_xyz=placement_plan.require_pose(cid).axis_xyz,
|
|
tags=[
|
|
f"formula.run_id.{instance.run_id}",
|
|
f"formula.node.{cid}",
|
|
f"formula.stage.{sid}",
|
|
f"formula.local_role.{local}",
|
|
f"formula.role.{_component_role(instance, cid)}",
|
|
],
|
|
assembled_step_path=component_step_paths.get(cid),
|
|
)
|
|
planet_spec = _gear_spec(ctx, "planet")
|
|
for index in range(ctx.stage.planet_count):
|
|
cid = f"{sid}_planet_{index + 1}"
|
|
pose = placement_plan.require_pose(cid)
|
|
components[cid] = ComponentManifest(
|
|
component_id=cid,
|
|
formula_node=f"{sid}_planet",
|
|
stage_id=sid,
|
|
local_role="planet",
|
|
role="planet",
|
|
kind="gear",
|
|
gear_type="external",
|
|
tooth_count=ctx.stage.z_planet,
|
|
tooth_form=ctx.stage.tooth_form,
|
|
helix_angle_deg=planet_spec.helix_angle_deg,
|
|
helix_hand=planet_spec.helix_hand,
|
|
module_mm=ctx.stage.module_mm,
|
|
pressure_angle_deg=ctx.stage.pressure_angle_deg,
|
|
face_width_mm=ctx.stage.face_width_mm,
|
|
pitch_radius_mm=ctx.derived.planet_pitch_radius_mm,
|
|
center_xyz_mm=pose.center_xyz_mm,
|
|
axis_xyz=pose.axis_xyz,
|
|
tags=[
|
|
f"formula.run_id.{instance.run_id}",
|
|
f"formula.node.{cid}",
|
|
f"formula.stage.{sid}",
|
|
"formula.local_role.planet",
|
|
],
|
|
assembled_step_path=component_step_paths.get(cid),
|
|
)
|
|
return components
|
|
|
|
|
|
def _constraint_manifests(instance: ReducerInstance) -> list[ConstraintManifest]:
|
|
constraints: list[ConstraintManifest] = []
|
|
if not instance.requirement.stages or not instance.parameters.stages:
|
|
return constraints
|
|
for stage_requirement in instance.requirement.stages:
|
|
sid = stage_requirement.stage_id
|
|
stage = stage_parameter_by_id(instance.parameters, sid)
|
|
if stage_requirement.fixed is not None:
|
|
fixed_component = stage_component_id(sid, stage_requirement.fixed)
|
|
constraints.append(
|
|
ConstraintManifest(
|
|
constraint_id=f"{sid}_{stage_requirement.fixed}_housing_fixed",
|
|
relation_type="fixed",
|
|
component_a=fixed_component,
|
|
component_b="housing",
|
|
source="simplecadapi_constraint",
|
|
cad_constraint_kind="fixed",
|
|
stage_id=sid,
|
|
relation_id=f"{sid}_{stage_requirement.fixed}_housing_fixed",
|
|
)
|
|
)
|
|
for local in ["sun", "ring", "carrier"]:
|
|
if local == stage_requirement.fixed:
|
|
continue
|
|
component = stage_component_id(sid, local)
|
|
constraints.append(
|
|
ConstraintManifest(
|
|
constraint_id=f"{component}_{_component_role(instance, component)}_revolute",
|
|
relation_type="revolute_joint",
|
|
component_a=fixed_component,
|
|
component_b=component,
|
|
source="simplecadapi_constraint",
|
|
cad_constraint_kind="revolute",
|
|
stage_id=sid,
|
|
)
|
|
)
|
|
for index in range(stage.planet_count):
|
|
planet_id = f"{sid}_planet_{index + 1}"
|
|
constraints.extend(
|
|
[
|
|
ConstraintManifest(
|
|
constraint_id=f"{planet_id}_pin_revolute",
|
|
relation_type="revolute_joint",
|
|
component_a=stage_component_id(sid, "carrier"),
|
|
component_b=planet_id,
|
|
source="simplecadapi_constraint",
|
|
cad_constraint_kind="revolute",
|
|
stage_id=sid,
|
|
relation_id=f"{sid}_planet_{index + 1}_pin_revolute",
|
|
),
|
|
ConstraintManifest(
|
|
constraint_id=f"{sid}_sun_to_planet_{index + 1}_external_mesh",
|
|
relation_type="external_mesh",
|
|
component_a=stage_component_id(sid, "sun"),
|
|
component_b=planet_id,
|
|
source="simplecadapi_constraint",
|
|
cad_constraint_kind="gear",
|
|
stage_id=sid,
|
|
relation_id=f"{sid}_sun_to_planet_{index + 1}_external_mesh",
|
|
),
|
|
ConstraintManifest(
|
|
constraint_id=f"{sid}_ring_to_planet_{index + 1}_internal_mesh",
|
|
relation_type="internal_mesh",
|
|
component_a=stage_component_id(sid, "ring"),
|
|
component_b=planet_id,
|
|
source="simplecadapi_constraint",
|
|
cad_constraint_kind="belt",
|
|
stage_id=sid,
|
|
relation_id=f"{sid}_ring_to_planet_{index + 1}_internal_mesh",
|
|
),
|
|
]
|
|
)
|
|
first, second = instance.requirement.stages
|
|
source = stage_component_id(first.stage_id, first.output)
|
|
target = stage_component_id(second.stage_id, second.input)
|
|
constraints.extend(
|
|
[
|
|
ConstraintManifest(
|
|
constraint_id="cascade_input_shaft_rigid_to_s1_sun",
|
|
relation_type="rigid",
|
|
component_a="cascade_input_shaft",
|
|
component_b=stage_component_id(first.stage_id, first.input),
|
|
source="formula_semantic",
|
|
cad_constraint_kind=None,
|
|
stage_id="cascade",
|
|
relation_id="cascade_input_shaft_to_stage1_input",
|
|
),
|
|
ConstraintManifest(
|
|
constraint_id="s2_carrier_rigid_to_cascade_output_shaft",
|
|
relation_type="rigid",
|
|
component_a=stage_component_id(second.stage_id, second.output),
|
|
component_b="cascade_output_shaft",
|
|
source="formula_semantic",
|
|
cad_constraint_kind=None,
|
|
stage_id="cascade",
|
|
relation_id="cascade_stage2_output_to_output_shaft",
|
|
),
|
|
ConstraintManifest(
|
|
constraint_id=f"{source}_rigid_to_{target}",
|
|
relation_type="rigid",
|
|
component_a=source,
|
|
component_b=target,
|
|
source="formula_semantic",
|
|
cad_constraint_kind=None,
|
|
stage_id="cascade",
|
|
relation_id=f"{source}_rigid_to_{target}",
|
|
),
|
|
ConstraintManifest(
|
|
constraint_id=f"{source}_rigid_to_interstage_shaft",
|
|
relation_type="rigid",
|
|
component_a=source,
|
|
component_b="interstage_shaft",
|
|
source="simplecadapi_constraint",
|
|
cad_constraint_kind="fixed",
|
|
stage_id="cascade",
|
|
relation_id="cascade_stage1_output_to_interstage_shaft",
|
|
),
|
|
ConstraintManifest(
|
|
constraint_id=f"interstage_shaft_rigid_to_{target}",
|
|
relation_type="rigid",
|
|
component_a="interstage_shaft",
|
|
component_b=target,
|
|
source="simplecadapi_constraint",
|
|
cad_constraint_kind="fixed",
|
|
stage_id="cascade",
|
|
relation_id="cascade_interstage_shaft_to_stage2_input",
|
|
),
|
|
ConstraintManifest(
|
|
constraint_id="interstage_coupler_fixed_to_interstage_shaft",
|
|
relation_type="rigid",
|
|
component_a="interstage_coupler",
|
|
component_b="interstage_shaft",
|
|
source="simplecadapi_constraint",
|
|
cad_constraint_kind="fixed",
|
|
stage_id="cascade",
|
|
relation_id="cascade_interstage_coupler_to_shaft",
|
|
),
|
|
ConstraintManifest(
|
|
constraint_id="interstage_shaft_supported_by_housing",
|
|
relation_type="bearing_support",
|
|
component_a="interstage_shaft",
|
|
component_b="housing",
|
|
source="formula_semantic",
|
|
cad_constraint_kind=None,
|
|
stage_id="cascade",
|
|
relation_id="interstage_shaft_supported_by_housing",
|
|
),
|
|
]
|
|
)
|
|
for component_id in ["interstage_bearing_1", "interstage_bearing_2"]:
|
|
constraints.append(
|
|
ConstraintManifest(
|
|
constraint_id=f"{component_id}_outer_fixed_to_housing",
|
|
relation_type="bearing_support",
|
|
component_a=component_id,
|
|
component_b="housing",
|
|
source="simplecadapi_constraint",
|
|
cad_constraint_kind="fixed",
|
|
stage_id="cascade",
|
|
relation_id=f"{component_id}_outer_fixed_to_housing",
|
|
)
|
|
)
|
|
for component_id in ["interstage_support_1", "interstage_support_2"]:
|
|
constraints.append(
|
|
ConstraintManifest(
|
|
constraint_id=f"{component_id}_bolted_to_housing",
|
|
relation_type="bolted",
|
|
component_a=component_id,
|
|
component_b="housing",
|
|
source="simplecadapi_constraint",
|
|
cad_constraint_kind="fixed",
|
|
stage_id="cascade",
|
|
relation_id=f"{component_id}_bolted_to_housing",
|
|
)
|
|
)
|
|
return constraints
|
|
|
|
|
|
def generate_simplecad_cascade(instance: ReducerInstance, out_dir: Path) -> AssemblyManifest:
|
|
from .simplecad_generator import export_model_session_json, guard_no_gear_test, import_simplecadapi
|
|
|
|
guard_no_gear_test()
|
|
if instance.parameters.tooth_form not in {"spur", "helical"}:
|
|
raise RuntimeError(f"unsupported_tooth_form: {instance.parameters.tooth_form}")
|
|
scad = import_simplecadapi()
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
step_path = out_dir / "reducer.step"
|
|
model_path = out_dir / "reducer.model.json"
|
|
session_path = out_dir / "reducer.session.json"
|
|
manifest_path = out_dir / "assembly_manifest.json"
|
|
build_meta_path = out_dir / "build_meta.json"
|
|
placement_plan_path = out_dir / "placement_plan.json"
|
|
graph_id = f"{instance.run_id}_simple_2kh_cascade_{instance.parameters.tooth_form}_generation"
|
|
|
|
contexts = _stage_contexts(instance)
|
|
transmission_design = compute_cascade_interstage_design(instance)
|
|
placement_plan = solve_reducer_placements(instance)
|
|
with scad.GraphSession(graph_id=graph_id) as session:
|
|
materials = make_materials(scad)
|
|
parts = _stage_parts(scad, contexts=contexts, placement_plan=placement_plan, materials=materials)
|
|
assembly = _build_cascade_assembly(
|
|
scad,
|
|
instance=instance,
|
|
contexts=contexts,
|
|
placement_plan=placement_plan,
|
|
parts=parts,
|
|
)
|
|
preview = scad.make_compound_from_assembly_rcompound(assembly=assembly)
|
|
component_step_paths = _export_assembled_component_steps(scad, assembly=assembly, out_dir=out_dir)
|
|
model_json, session_json = export_model_session_json(scad, session=session)
|
|
|
|
model_path.write_text(model_json, encoding="utf-8")
|
|
session_path.write_text(session_json, encoding="utf-8")
|
|
scad.export_step(shapes=preview, filename=str(step_path))
|
|
write_model_json(placement_plan_path, placement_plan)
|
|
|
|
model_payload = json.loads(model_json)
|
|
manifest = AssemblyManifest(
|
|
run_id=instance.run_id,
|
|
formula_instance_hash=sha256_json(instance.model_dump(mode="json")),
|
|
generated_at=utc_now_iso(),
|
|
generator=f"simplecadapi_{instance.parameters.tooth_form}_simple_2kh_cascade_v0.1.0",
|
|
components=_component_manifests(instance, contexts, placement_plan=placement_plan, component_step_paths=component_step_paths),
|
|
constraints=_constraint_manifests(instance),
|
|
exported_files={
|
|
"step": str(step_path.resolve()),
|
|
"model": str(model_path.resolve()),
|
|
"session": str(session_path.resolve()),
|
|
"manifest": str(manifest_path.resolve()),
|
|
"build_meta": str(build_meta_path.resolve()),
|
|
"component_steps_dir": str((out_dir / "component_steps").resolve()),
|
|
"placement_plan": str(placement_plan_path.resolve()),
|
|
},
|
|
geometry_summary={
|
|
"preview_solids": len(preview.get_solids()),
|
|
"preview_volume": float(preview.get_volume()),
|
|
"model_graph_nodes": len(model_payload.get("graph", {}).get("nodes", [])),
|
|
"assembled_component_step_count": len(component_step_paths),
|
|
"stage_count": len(contexts),
|
|
"stage_gap_mm": float((instance.derived.axial_stack or {}).get("stage_gap_mm", 2.0)),
|
|
"interstage_transmission": {
|
|
"source_component_id": transmission_design.source_component_id,
|
|
"target_component_id": transmission_design.target_component_id,
|
|
"shaft_diameter_mm": transmission_design.shaft_diameter_mm,
|
|
"bearing_inner_diameter_mm": transmission_design.bearing_inner_diameter_mm,
|
|
"bearing_outer_diameter_mm": transmission_design.bearing_outer_diameter_mm,
|
|
"bearing_width_mm": transmission_design.bearing_width_mm,
|
|
"bearing_ball_diameter_mm": transmission_design.bearing_ball_diameter_mm,
|
|
"bearing_ball_count": transmission_design.bearing_ball_count,
|
|
"bearing_centers_z_mm": transmission_design.bearing_centers_z_mm,
|
|
"support_centers_z_mm": transmission_design.support_centers_z_mm,
|
|
},
|
|
"tooth_form": instance.parameters.tooth_form,
|
|
"helix_angle_deg": instance.parameters.helix_angle_deg,
|
|
"helix_hand": instance.parameters.helix_hand,
|
|
"placement_solver": placement_plan.solver_version,
|
|
"stage_phase_policy": {
|
|
ctx.stage.stage_id: {
|
|
"internal_ring_phase_correction_deg": internal_ring_phase_correction_degrees(
|
|
z_planet=ctx.stage.z_planet,
|
|
z_ring=ctx.stage.z_ring,
|
|
),
|
|
"planet_phase_formula": "180 - 180 / z_planet + planet_center_angle_deg * (1 + z_sun / z_planet)",
|
|
}
|
|
for ctx in contexts
|
|
},
|
|
},
|
|
)
|
|
write_model_json(manifest_path, manifest)
|
|
build_meta = {
|
|
"run_id": instance.run_id,
|
|
"formula_instance_hash": manifest.formula_instance_hash,
|
|
"generator": manifest.generator,
|
|
"simplecadapi_file": getattr(scad, "__file__", "unknown"),
|
|
"graph_id": graph_id,
|
|
"topology_family": instance.topology_family,
|
|
"boundary": instance.boundary.model_dump(mode="json"),
|
|
"axial_stack": instance.derived.axial_stack,
|
|
"assembly_id": getattr(assembly, "assembly_id", None),
|
|
"component_ids": assembly.component_ids(),
|
|
"constraint_ids": assembly.constraint_ids(),
|
|
"preview_solids": manifest.geometry_summary["preview_solids"],
|
|
"preview_volume": manifest.geometry_summary["preview_volume"],
|
|
"model_graph_nodes": manifest.geometry_summary["model_graph_nodes"],
|
|
"stage_phase_policy": manifest.geometry_summary["stage_phase_policy"],
|
|
"placement_plan_hash": sha256_json(placement_plan.model_dump(mode="json")),
|
|
"exported_files": manifest.exported_files,
|
|
"file_mtime_ns": {
|
|
"step": step_path.stat().st_mtime_ns,
|
|
"model": model_path.stat().st_mtime_ns,
|
|
"session": session_path.stat().st_mtime_ns,
|
|
"placement_plan": placement_plan_path.stat().st_mtime_ns,
|
|
},
|
|
"generated_at": utc_now_iso(),
|
|
}
|
|
write_json(build_meta_path, build_meta)
|
|
return manifest
|