diff --git a/design/mechanical/MECHANICAL_DESIGN.md b/design/mechanical/MECHANICAL_DESIGN.md new file mode 100644 index 0000000..bd56e8a --- /dev/null +++ b/design/mechanical/MECHANICAL_DESIGN.md @@ -0,0 +1,780 @@ +# NIGHTWATCH — Observatory Mechanical Design: Rigorous Proof-Out + +> **Generated artifact.** Every number below is computed by the `design/mechanical/calc/` +> package and regenerated by `python3 -m design.mechanical.calc.report`. The test +> `test_report.py` fails if this file drifts from the calculator, so the figures cannot +> silently disagree the way the repo's docs currently do. +> +> **Honesty labels.** Every input is tagged **S** (sourced — stated in a repo file), +> **D** (derived — computed here), or **A** (assumed — the repo is silent; an engineering +> assumption is stated). Nothing here is measured field data; the observatory has never been built. + +## 1. Why this exists + +The repo already documents a mechanical design — `NIGHTWATCH_Build_Package.md` (spec table, costed +BOM, concrete-pier spec) and a `pos/` panel of expert personas (C. Walton Musser on the harmonic +drives, Richard Hedrick on frame stiffness). But it is **assertion, not proof**: the headline +targets are stated, never calculated, and the single most load-bearing fact — which telescope — +contradicts itself. This report turns the prose into computed, self-consistent, margin-carrying +engineering, runs the selection permutations as a real weighted trade study, makes and defends the +bold choices the numbers justify, and fills the CAD and environmental-load voids. + +## 2. Resolved contradictions + +The repo carries numbers that disagree across files. This design pins each to one value in +`params.py` and records the conflict so it is fixed, not buried. + +| # | Contradiction (as found) | Resolution here | +|---|---|---| +| 1 | **OTA identity**: MN76 178 mm f/6 1068 mm **~9 kg** (Build Package) vs MN78 180 mm **f/8** 1440 mm **~14 kg** (`INTES_MICRO_HISTORY.md`, "selected") | Both carried as load cases (`MN76`, `MN78`); the torque + stiffness proofs and the trade study decide (see §Trade Study). | +| 2 | **Encoder PPR**: AMT103 "8192 PPR" everywhere vs "2048 PPR" (`HARDWARE_SETUP.md`) | Pinned to 8192 PPR motor-side; 2048 flagged as a doc error. | +| 3 | **On-axis resolution**: AS5600 "12-bit / 4096" vs `EncoderConfig.resolution=8192` | AS5600 is 12-bit → 4096 counts/rev = ~316 arcsec/count; the 8192 config default is unrelated and misleading. | +| 4 | **Horizon/altitude limit**: 10° (safety monitor) / 15° (`constants.py`) / 20° (scheduler) | Not a mechanical limit — advisory; the mount has no hard slew-altitude cutoff (a noted gap). | +| 5 | **Operating temperature**: −20…40 °C (YAML) vs 20…100 °F (code) | Reconciled to °C internally (−6.7…38 °C from the code Fahrenheit values). | +| 6 | **Site coordinates**: 38.9 / −117.4 (software) vs 39.0 / −117.0 (firmware) | Pinned to 38.9 °N / −117.4 °W, 1800 m for all load derivations. | +| 7 | **Bearings** called "angular contact" but specified as 6008/6006, which are **deep-groove** | Flagged; the bearings + stiffness proofs recommend 7008/7006 angular-contact pairs for moment stiffness. | + +## 3. The three governing voids (repo is silent — filled here, labelled ASSUMED) + +A 6000 ft, seismically-active, remote Nevada site is governed by three structural loads the repo +never states. The software encodes only *operational* interlocks (park at 25 mph, close at 35 mph +gust), which are **not** structural survival ratings. This design makes them first-class: + +| Void | Repo status | Assumed design load (A) | Governs | +|---|---|---|---| +| **Survival wind** | absent (only 25/35 mph operational) | ASCE 7 basic wind ~105 mph 3-sec gust, Risk Cat I | roof anchors, pier overturning | +| **Snow load** | absent entirely | ~25 psf ground snow, high-desert @ 6000 ft | closed-roof structure, roof slope | +| **Seismic** | absent entirely | S_DS ≈ 0.5 g (Walker Lane vicinity) | pier base shear, anchorage | + +Two more the repo flags as open and this design closes: **DGX Spark power/heat** (assumed ~170 W → +energy + enclosure-thermal budget) and the **power/autonomy budget** (solar+battery autonomy hours). + +## 4. Results at a glance + +| # | Proof | Verdict | Governing number | +|---|---|---|---| +| 1 | Axis torque budget | ✅ PASS | Intes-Micro MN78 (f/8), counterweight-FREE: RA needs 49.9 Nm vs 127 Nm rated (SF 2.5); DEC needs 26.5 Nm vs 70 Nm rated (SF 2.6). | +| 2 | Static pointing deflection (OTA horizontal, worst case) | ❌ FAIL | Intes-Micro MN78 (f/8): 8 mm baseline 43.5" (FAIL), 12 mm bold 43.3" (FAIL) vs 5" target -- bearing compliance at the 63.5/76.2 mm spans governs. | +| 3 | First natural frequency (structural first mode) | ✅ PASS | Intes-Micro MN78 (f/8): governing first mode 65 Hz (rocking 65 Hz, bounce 169-220 Hz) vs 10 Hz target -- SF 6.5, clear of the <2 Hz wind and 1-5 Hz servo bands. | +| 4 | Tracking-error (pointing) budget | ❌ FAIL | Baseline dual-encoder chain reaches only 5.02 arcsec RMS, FAILING the 1.0 arcsec target; the on-axis RESA ring reaches 0.54 arcsec RMS. An on-axis high-resolution absolute encoder is REQUIRED to reach sub-arcsecond. | +| 5 | Mass-balance proof (counterweighted & counterweight-free) | ✅ PASS | Intes-Micro MN78 (f/8): 12.5 kg balances the 18 kg payload at r_cw=288 mm on the 457 mm shaft (fit factor 1.59). Counterweight-FREE deletes 15.4 kg and 29% of RA inertia — viable per the torque proof (RA SF 2.5). | +| 6 | Axis bearing L10 fatigue + static safety | ✅ PASS | Load capacity is NOT the constraint: L10 ~ 5e+07 yr (RA) / 3e+07 yr (DEC) at ~1 rev/sidereal-day, static S0 18x / 14x. FINDING: the repo calls the 6008/6006 'angular contact' but 60xx are DEEP-GROOVE with poor moment stiffness — use matched angular-contact 7008/7006 pairs (back-to-back) for moment stiffness (see stiffness proof). | +| 7 | Wind structural loads (operational drag + survival uplift) | ❌ FAIL | Survival wind (105 mph) GOVERNS: 9.2 kN roof uplift vs 1.8 kN roof self-weight (SF 0.19) -- hold-down anchors are MANDATORY and unspecified in the repo (4x 2 klbf anchors -> SF 3.9). Operational gust drag on Intes-Micro MN78 (f/8) is only 41 N. | +| 8 | Pier / foundation (tilt, frequency, seismic, concrete) | ✅ PASS | 12"x36" concrete pier: tilt 0.70" (SF 7), f_n 152 Hz (SF 15), seismic overturning SF 7.0 (dead-weight-only 0.81 -> embedment governs), concrete SF 14. Governing SF 6.8 (PASS). | +| 9 | Thermal focus stability over the diurnal swing | ❌ FAIL | Passive focus is NOT enough: over the 22 K diurnal swing the 6061-T6 tube walks focus ~10x the f/8 depth of focus (only 2.1 K to leave best focus) -> a temperature-compensated focuser is REQUIRED (the repo focuser has a -2.5 steps/C coefficient). The Astrositall mirror adds only ~5 um (0.7% of the tube term); a ~1.3 W corrector heater beats the 2 W all-sky ring. | +| 10 | Roll-off roof drive + snow load | ✅ PASS | Drive PASS: moving the 180 kg roof against rolling + the 35 mph close gust needs 235 N (11.8 Nm @ 5 cm wheel), SF 2.1 vs a ~500 N garage-door-class drive — and wind is 62% of that load, so the drive is WIND-sized. FINDING: the closed flat roof carries 10.8 kN of snow (~6.1x its dead weight) — the snow case is the roof's structural design driver; a sloped roof sheds it, and a snow-laden roof CANNOT be driven -> snow interlock required. | +| 11 | Night-time energy budget (UPS + solar autonomy) | ❌ FAIL | Night load ~287 W, 59% of it the ASSUMED 170 W DGX Spark. The 1500 VA/900 W UPS rides shutdown with 16x margin (~31 min) but only ~5% of a night. Off-grid AUTONOMY FAIL: the specified 400 W + 100 Ah pack gives only 3.3 h at 12 V (< 10 h night) — a 48 V pack gives 13.4 h — and the panel harvests ~1.4 kWh/day vs ~2.9 kWh needed. Drop/duty-cycle the DGX or go 48 V for true autonomy. (DGX dumps ~14 K into the enclosure at 2 ACH -> ventilate or locate it outside.) | + +**6 PASS · 0 MARGINAL · 5 FAIL.** The FAILs are the value: each is a real gap in the repo's design that assertion had hidden — the bearing-limited stiffness, the sub-arcsec-incapable encoder chain, the missing survival-wind anchors, passive focus over the diurnal swing, and off-grid autonomy. Every FAIL carries a computed remedy below. + +## 5. The proofs + +### 1. Axis torque budget — ✅ PASS + +**Intes-Micro MN78 (f/8), counterweight-FREE: RA needs 49.9 Nm vs 127 Nm rated (SF 2.5); DEC needs 26.5 Nm vs 70 Nm rated (SF 2.6).** + +*Target:* Required axis torque < rated (SF>=2 continuous, peak covers goto) +*Governing safety factor:* 2.55× + +| Quantity | Value | Unit | Note | +|---|---:|---|---| +| Payload mass (OTA + train) | 18 | kg | | +| RA gravity imbalance torque | 35.3 | Nm | payload weight x CG offset | +| RA wind torque @ 35 mph gust | 8.13 | Nm | | +| RA goto-inertia torque | 0.105 | Nm | | +| RA friction torque | 6.35 | Nm | | +| RA total required | 49.89 | Nm | | +| RA rated / required (SF) | 2.546 | x | | +| RA peak / required (SF) | 6.875 | x | | +| DEC total required | 26.53 | Nm | | +| DEC rated / required (SF) | 2.639 | x | | +| DEC peak / required (SF) | 7.011 | x | | + +_Assumptions:_ +- RA CG offset from polar axis = 200 mm (GEM geometry estimate, DERIVED) +- Balance residual (counterweighted) = 2% (ASSUMED) +- Drive+bearing friction = 5% of rated torque (ASSUMED) +- DEC wind center-of-pressure at 34% of tube length from the DEC pivot (ASSUMED, conservative; governs the DEC total). +- Wind torque uses the 35 mph emergency-close gust (max wind while open), SOURCED. +- Air density 1.027 kg/m^3 at 1800 m (DERIVED), 17% below sea level. + +### 2. Static pointing deflection (OTA horizontal, worst case) — ❌ FAIL + +**Intes-Micro MN78 (f/8): 8 mm baseline 43.5" (FAIL), 12 mm bold 43.3" (FAIL) vs 5" target -- bearing compliance at the 63.5/76.2 mm spans governs.** + +*Target:* Summed gravity deflection < 5 arcsec (Hedrick), SF>=1.5 to PASS +*Governing safety factor:* 0.12× + +| Quantity | Value | Unit | Note | +|---|---:|---|---| +| Payload mass (OTA + train) | 18 | kg | | +| Gravity lever L_cg (cg + saddle) | 0.2833 | m | along-tube CG + assumed 50 mm saddle offset | +| Gravity moment at DEC axis | 50.01 | Nm | W x L_cg, tube horizontal | +| -- 8 mm baseline housings -- | 0 | | | +| DEC housing beam deflection | 0.5903 | arcsec | | +| RA housing beam deflection | 0.2871 | arcsec | | +| DEC bearing-pair tilt | 28.43 | arcsec | 6006 @ 180 N/um, 63.5 mm span | +| RA bearing-pair tilt | 14.21 | arcsec | 6008 @ 250 N/um, 76.2 mm span | +| 8 mm TOTAL deflection | 43.52 | arcsec | | +| 8 mm target / actual (SF) | 0.1149 | x | FAIL | +| -- 12 mm bold housings -- | 0 | | | +| 12 mm beam deflection (DEC+RA) | 0.6295 | arcsec | | +| 12 mm bearing tilt (DEC+RA) | 42.64 | arcsec | unchanged -- wall does not stiffen bearings | +| 12 mm TOTAL deflection | 43.27 | arcsec | | +| 12 mm target / actual (SF) | 0.1156 | x | FAIL | +| Bearing share of total (8 mm) | 97.98 | % | | + +_Assumptions:_ +- Worst case: OTA horizontal, gravity perpendicular to the optical axis (SOURCED as the design case). +- L_cg = OTA.cg_from_saddle (233 mm) + saddle offset 50 mm (saddle undimensioned in repo, ASSUMED). +- Bearing radial stiffness 6008~250 N/um, 6006~180 N/um (repo lists only load ratings, not stiffness) -- ASSUMED. +- Bearing span = housing depth (bearings at the box faces): DEC 63.5 mm, RA 76.2 mm. +- The full payload moment is applied through BOTH the DEC and RA load paths (overhung bending on the RA bearings persists even when torque-balanced) -- a bounding assumption. +- Deep-groove bearings modelled; the repo labels them 'angular contact' (flagged in params) -- a preloaded angular-contact pair would raise stiffness materially. + +### 3. First natural frequency (structural first mode) — ✅ PASS + +**Intes-Micro MN78 (f/8): governing first mode 65 Hz (rocking 65 Hz, bounce 169-220 Hz) vs 10 Hz target -- SF 6.5, clear of the <2 Hz wind and 1-5 Hz servo bands.** + +*Target:* First mode > 10 Hz (Hedrick), SF>=1.5 to PASS; must clear 1-5 Hz servo +*Governing safety factor:* 6.46× + +| Quantity | Value | Unit | Note | +|---|---:|---|---| +| Pier lateral stiffness | 4.133e+07 | N/m | concrete cantilever 3EI/H^3 | +| Head series stiffness k_eff | 3.440e+07 | N/m | pier+beams+bearings in series | +| Payload mass (CW-free) | 18 | kg | | +| Payload + counterweights | 30.5 | kg | | +| Bounce mode, CW-free | 220 | Hz | translational | +| Bounce mode, counterweighted | 169 | Hz | heavier -> lower f | +| Rocking mode (governing) | 64.65 | Hz | overhung on bearing angular stiffness | +| Governing first mode | 64.65 | Hz | min of all modes | +| Governing / target (SF) | 6.465 | x | PASS | +| Wind-gust excitation band | 2 | Hz | must stay below f_n | +| Servo/guide bandwidth | 5 | Hz | must stay below f_n | + +_Assumptions:_ +- Pier modelled as a solid concrete lateral cantilever (I = pi*D^4/64); its own mass and soil/footing compliance are neglected (stiffer, non-conservative for the pier term but it is not governing). +- Bearing radial stiffness 6008~250 N/um, 6006~180 N/um (ASSUMED, shared with stiffness.py); rocking uses the derived angular stiffness k*span^2/2. +- Modal mass = payload (+ 12.5 kg counterweights); head/drive masses and pier participation neglected -> the true modes are slightly lower. +- Rocking inertia J = m*L_cg^2 with L_cg the same gravity lever as stiffness.py (233 mm CG + 50 mm saddle); point-mass approximation. +- 12 mm bold housings used for the head beams (recommended build); beam terms are negligible vs pier and bearing terms either way. +- Verdict taken on the lowest mode found (rocking), not the higher bounce mode -- the honest first mode. + +### 4. Tracking-error (pointing) budget — ❌ FAIL + +**Baseline dual-encoder chain reaches only 5.02 arcsec RMS, FAILING the 1.0 arcsec target; the on-axis RESA ring reaches 0.54 arcsec RMS. An on-axis high-resolution absolute encoder is REQUIRED to reach sub-arcsecond.** + +*Target:* Tracking error < 1.0 arcsec RMS (P.TRACKING_RMS_TARGET_ARCSEC) +*Governing safety factor:* 0.20× + +| Quantity | Value | Unit | Note | +|---|---:|---|---| +| AMT103 motor-side resolution (to axis) | 1.582 | arcsec/LSB | 8192 PPR x100 upstream ratio | +| AS5600 on-axis resolution | 316.4 | arcsec/LSB | 12-bit => quant RMS 91", homing-grade only | +| RESA on-axis resolution (proposed) | 0.005538 | arcsec/LSB | absolute ring, sub-arcsec | +| Baseline: motor quant RMS | 0.4567 | arcsec | res/sqrt(12) | +| Baseline: harmonic PE residual | 4 | arcsec | unseen by motor enc | +| Baseline: drivetrain/mount flexure | 3 | arcsec | unseen by motor enc | +| Baseline total tracking RMS | 5.021 | arcsec | RSS of the three | +| Baseline margin (target/achieved) | 0.1992 | x | <1 => fails | +| Proposed: RESA quant RMS | 0.001599 | arcsec | res/sqrt(12) | +| Proposed: servo following error | 0.2 | arcsec | on-axis closed loop | +| Proposed: residual OTA flexure | 0.5 | arcsec | downstream of ring | +| Proposed total tracking RMS | 0.5385 | arcsec | RSS of the three | +| Proposed margin (target/achieved) | 1.857 | x | >1 => meets | + +_Assumptions:_ +- Quantisation RMS = LSB/sqrt(12) (ideal uniform quantiser). +- Baseline servo closes on the MOTOR encoder (1.58" to axis); the AS5600's 91" quant RMS makes it a homing reference only, so harmonic PE and mount flexure leak into pointing. SOURCED chain. +- Harmonic PE residual = 4.0" RMS (ASSUMED), consistent with CSF repeatability P.RA_DRIVE=6" / P.DEC_DRIVE=8". +- Drivetrain + mount flexure = 3.0" RMS (ASSUMED, stated structural budget), entirely upstream of the axis and thus invisible to a motor encoder. +- Proposed on-axis ring corrects everything upstream of the axis; only servo following (0.2" RMS, ASSUMED) and residual OTA/focuser flexure (0.5" RMS, ASSUMED) downstream of the ring remain. +- Terms combine in RSS (independent, zero-mean error sources). + +### 5. Mass-balance proof (counterweighted & counterweight-free) — ✅ PASS + +**Intes-Micro MN78 (f/8): 12.5 kg balances the 18 kg payload at r_cw=288 mm on the 457 mm shaft (fit factor 1.59). Counterweight-FREE deletes 15.4 kg and 29% of RA inertia — viable per the torque proof (RA SF 2.5).** + +*Target:* RA balance achievable within shaft length AND counterweight-free viable (torque proof) +*Governing safety factor:* 1.59× + +| Quantity | Value | Unit | Note | +|---|---:|---|---| +| Payload mass (OTA + train) | 18 | kg | | +| Payload CG offset from polar axis (r_payload) | 0.2 | m | torque.RA_CG_OFFSET_M | +| Payload moment about RA axis | 3.6 | kg*m | m_payload * r_payload | +| Counterweights available | 12.5 | kg | 2x5 + 1x2.5 kg | +| Required shaft position r_cw | 0.288 | m | m_cw * r_cw = m_pay * r_pay | +| Shaft length available | 0.4572 | m | 18" 303-SS shaft | +| Fit factor (shaft_len / r_cw) | 1.587 | x | >1 => fits | +| DEC rear-stack imbalance moment | 5.884 | Nm | train weight x 150 mm; nulled by dovetail slide | +| DEC residual moment after adjust | 3.53 | Nm | residual lever 20 mm (torque.DEC_RESIDUAL_LEVER_M) | +| Counterweight shaft mass (rho*A*L) | 2.896 | kg | 303-SS solid rod | +| Total deleted mass (cw-free) | 15.4 | kg | weights + shaft | +| RA payload inertia (polar axis) | 3.007 | kg*m^2 | | +| Deleted RA inertia (weights+shaft) | 1.239 | kg*m^2 | | +| RA inertia reduction (cw-free) | 29.18 | % | | +| Counterweight-free viable (torque) | 1 | bool | torque verdict PASS | + +_Assumptions:_ +- r_payload = RA CG offset 200 mm (imported from torque.RA_CG_OFFSET_M, DERIVED). +- Counterweights modelled as a point mass; the shaft's own distributed moment is NOT credited toward balance -> conservative (pushes r_cw outward, not inward). +- Counterweight shaft mass = rho*A*L of 303-SS (rho=8000 kg/m^3, d=31.8 mm, L=457 mm) = 2.90 kg, DERIVED. +- DEC fore-aft balance is set by sliding the OTA in its dovetail saddle (no DEC counterweight); residual lever 20 mm from torque.DEC_RESIDUAL_LEVER_M, ASSUMED. +- Deleted RA inertia = weights as point mass at r_cw + shaft as a rod about the axis end (I = 1/3 m L^2), DERIVED. +- Counterweight-free viability is inherited from the torque proof (torque.evaluate('MN78', counterweight_free=True) => PASS), not re-derived here. + +### 6. Axis bearing L10 fatigue + static safety — ✅ PASS + +**Load capacity is NOT the constraint: L10 ~ 5e+07 yr (RA) / 3e+07 yr (DEC) at ~1 rev/sidereal-day, static S0 18x / 14x. FINDING: the repo calls the 6008/6006 'angular contact' but 60xx are DEEP-GROOVE with poor moment stiffness — use matched angular-contact 7008/7006 pairs (back-to-back) for moment stiffness (see stiffness proof).** + +*Target:* L10 >> service life AND static S0 >= 2 (fatigue not the constraint) +*Governing safety factor:* 13.66× + +| Quantity | Value | Unit | Note | +|---|---:|---|---| +| Payload mass (OTA + train) | 18 | kg | | +| RA overturning moment (W x CG offset) | 35.3 | Nm | lever = RA_CG_OFFSET_M | +| RA bearing span (housing depth) | 0.0762 | m | sets couple arm | +| RA output bearing load P | 639.8 | N | W + M/span (6008) | +| RA dynamic rating C | 1.68e+04 | N | | +| RA L10 life | 1.810e+10 | rev | (C/P)^3 x 1e6 | +| RA L10 life | 4.956e+07 | yr | @ ~1 rev/sidereal day | +| RA static safety S0 = C0/P | 18.13 | x | C0=11600 N | +| DEC overturning moment (W x lever) | 27.36 | Nm | lever = tube r + saddle stack | +| DEC bearing span (housing depth) | 0.0635 | m | | +| DEC output bearing load P | 607.4 | N | W + M/span (6006) | +| DEC dynamic rating C | 1.33e+04 | N | | +| DEC L10 life | 2.874e+07 | yr | @ ~1 rev/sidereal day | +| DEC static safety S0 = C0/P | 13.66 | x | C0=8300 N | + +_Assumptions:_ +- Governing bearing load P = W + M/span (direct transverse weight + moment couple); no load-sharing credit on the direct term -> conservative. +- RA lever = RA_CG_OFFSET_M 200 mm (from torque, DERIVED); DEC lever = tube radius 105 mm + saddle stack 50 mm (saddle stack ASSUMED). +- Bearing span = housing depth (P.RA_HOUSING.depth_m / P.DEC_HOUSING.depth_m); axis tilt at latitude would reduce the transverse component, so vertical W is conservative. +- L10 = (C/P)^3 x 1e6 rev; converted to years at 1 rev/sidereal day (~1/day). Both axes exceed 1e7 years -> rolling fatigue is NOT a lifetime constraint. +- FINDING: params labels BRG_RA_6008 / BRG_DEC_6006 'angular contact', but 60xx are DEEP-GROOVE ball bearings (params.py already flags the deep-groove C ratings). Deep-groove bearings have poor moment/tilting stiffness. +- RECOMMENDATION: replace with matched ANGULAR-CONTACT pairs — 7008 (RA) / 7006 (DEC) — in a back-to-back (O) arrangement with preload, which provides the moment stiffness that sets pointing deflection. This is a STIFFNESS decision, not a load-rating one (see stiffness proof). +- The RA output bearing additionally carries DEC-head and (if fitted) counterweight dead weight not modelled here; even at 2-3x the modelled load, S0 stays > 5 and L10 > 1e6 yr, so the conclusion is robust. + +### 7. Wind structural loads (operational drag + survival uplift) — ❌ FAIL + +**Survival wind (105 mph) GOVERNS: 9.2 kN roof uplift vs 1.8 kN roof self-weight (SF 0.19) -- hold-down anchors are MANDATORY and unspecified in the repo (4x 2 klbf anchors -> SF 3.9). Operational gust drag on Intes-Micro MN78 (f/8) is only 41 N.** + +*Target:* Survival roof uplift resisted by hold-down (self-weight insufficient); operational drag feeds torque/pier budgets +*Governing safety factor:* 0.19× + +| Quantity | Value | Unit | Note | +|---|---:|---|---| +| Air density @ 1800 m | 1.027 | kg/m^3 | ISA, 16% below sea level | +| q @ 25 mph park | 64.13 | Pa | | +| q @ 35 mph gust | 125.7 | Pa | | +| OTA drag @ 25 mph (park) | 20.74 | N | | +| OTA drag @ 35 mph (gust) | 40.65 | N | max wind while open | +| Wind moment at pier TOP @ gust | 20.32 | Nm | F x mount-head height | +| Wind moment at pier BASE @ gust | 57.5 | Nm | F x (pier + head height) | +| -- survival, roof closed -- | 0 | | | +| q @ 105 mph survival | 1131 | Pa | | +| Roof gross uplift | 9163 | N | q x GCp x A_roof | +| Roof self-weight | 1765 | N | | +| Roof NET uplift (anchor demand) | 7398 | N | uplift - self-weight | +| Roof self-weight / uplift (SF) | 0.1926 | x | FAIL | +| Assumed anchor capacity (4x) | 3.559e+04 | N | | +| Anchored hold-down (SF) | 3.884 | x | remediation | +| Enclosure lateral drag | 9774 | N | q x Cf x A_wall | +| Enclosure overturning moment | 1.173e+04 | Nm | about leeward base edge | +| Overturning, dead-weight only (SF) | 0.6592 | x | anchors required | +| Survival governs vs operational | 1 | bool | | + +_Assumptions:_ +- Dynamic pressure uses site air density 1.027 kg/m^3 (ISA @ 1800 m, DERIVED) — lower than sea level, so loads are honest not inflated. +- Operational max wind = 35 mph emergency-close gust (SOURCED safety monitor); the OTA is only exposed with the roof open, so it never sees survival wind. +- Survival basic wind 105 mph is ASSUMED (ASCE 7 central-NV Risk Cat I; repo is silent — a governing void). +- Roof footprint 9 m^2 and mass 180 kg are ASSUMED (params flags roof geometry as unspecified). +- Net uplift coefficient GCp = 0.9 (SOURCED param); wall force coefficient Cf = 1.2 on an assumed 2.4 m wall (ASSUMED). +- Mount-head height 500 mm (OTA axis above pier top) is ASSUMED; sets the operational overturning lever. +- Repo specifies NO roof hold-down or enclosure anchorage; the anchor scheme (4x 2 klbf, ASSUMED) is shown only as remediation, not as an existing spec. +- The pier is assumed structurally isolated from the enclosure (standard observatory practice), so roof uplift loads the enclosure foundation, not the pier. + +### 8. Pier / foundation (tilt, frequency, seismic, concrete) — ✅ PASS + +**12"x36" concrete pier: tilt 0.70" (SF 7), f_n 152 Hz (SF 15), seismic overturning SF 7.0 (dead-weight-only 0.81 -> embedment governs), concrete SF 14. Governing SF 6.8 (PASS).** + +*Target:* Tilt << 5"; f_n > 10 Hz; seismic stable (embed+weight); concrete < f'c & modulus of rupture +*Governing safety factor:* 6.83× + +| Quantity | Value | Unit | Note | +|---|---:|---|---| +| Pier I (pi d^4/64) | 4.237e-04 | m^4 | | +| Pier lateral stiffness k | 4.133e+07 | N/m | 3EI/L^3 cantilever | +| -- tilt (35 mph gust) -- | 0 | | | +| OTA drag @ 35 mph | 40.65 | N | from wind.py | +| Pier tip deflection | 0.9836 | um | F L^3/3EI | +| Pier pointing tilt | 0.6968 | arcsec | tip slope F L^2/2EI | +| Tilt budget / actual (SF) | 7.176 | x | vs 5" pointing budget | +| -- frequency -- | 0 | | | +| Tip mass (head+payload+CW) | 45.4 | kg | | +| Pier first mode | 151.9 | Hz | sqrt(k/m)/2pi | +| f_n / target (SF) | 15.19 | x | vs 10 Hz | +| -- seismic (S_DS=0.5) -- | 0 | | | +| Seismic weight W | 2016 | N | tip + exposed pier | +| Base shear V = S_DS W | 1008 | N | | +| Overturning moment | 673.8 | Nm | about grade | +| Soil passive resultant Pp | 6881 | N | assumed granular, Kp=3 | +| Overturning SF (embed+weight) | 7.036 | x | | +| Overturning SF (dead-weight only) | 0.811 | x | < 1 -> embedment required | +| Sliding SF (Pp / V) | 6.828 | x | | +| -- concrete stress -- | 0 | | | +| Axial bearing stress | 6101 | Pa | | +| Flexural stress (seismic) | 2.424e+05 | Pa | M c / I | +| Net compression / f'c (SF) | 111 | x | | +| Net tension / modulus rupture (SF) | 13.78 | x | cracking check | +| -- frost -- | 0 | | | +| Embedment / frost depth (SF) | 1.524 | x | 0.914 m vs assumed 0.6 m frost | +| Governing safety factor | 6.828 | x | PASS | + +_Assumptions:_ +- Pier modelled as a solid circular concrete cantilever fixed at grade (I = pi*d^4/64, k = 3EI/L^3); soil-spring base fixity and pier self-mass in the frequency term are neglected (slightly non-conservative on stiffness). +- Pointing tilt uses the cantilever tip SLOPE F*L^2/2EI (the true mount-base rotation), 1.5x the delta/L estimate of the tip deflection F*L^3/3EI. +- Tilt driven by the 35 mph operational gust (roof-open max); the OTA is shielded at survival wind, so wind never governs the pier structurally. +- Tip mass = assumed 12 kg head + payload (18 kg) + counterweights (15.4 kg, shaft mass DERIVED from geometry); head mass ASSUMED. +- Seismic S_DS = 0.5 g is ASSUMED (Walker Lane vicinity; repo silent). Simplified ELF: V = S_DS*W, two-mass overturning distribution. +- Soil ASSUMED medium-dense granular: gamma = 18 kN/m^3, Kp = 3.0 (phi ~ 30 deg); passive resultant over the 0.914 m embedment provides the overturning resistance — dead weight alone would not. +- Concrete f'c = 27.6 MPa (SOURCED); modulus of rupture f_r = 0.62*sqrt(f'c) = 3.26 MPa (ASSUMED code value) used for the tension/cracking check. +- Frost line ASSUMED 0.3-0.6 m for central-NV high desert; the 0.914 m embedment (SOURCED) exceeds it. +- Coupled head/bearing rocking (the true system first mode) is owned by dynamics.py; this module only confirms the pier itself is not the soft/low-frequency element. + +### 9. Thermal focus stability over the diurnal swing — ❌ FAIL + +**Passive focus is NOT enough: over the 22 K diurnal swing the 6061-T6 tube walks focus ~10x the f/8 depth of focus (only 2.1 K to leave best focus) -> a temperature-compensated focuser is REQUIRED (the repo focuser has a -2.5 steps/C coefficient). The Astrositall mirror adds only ~5 um (0.7% of the tube term); a ~1.3 W corrector heater beats the 2 W all-sky ring.** + +*Target:* Passive focus stays within +/- DoF over the 22 K swing (SF = K_to_edge / swing) +*Governing safety factor:* 0.10× + +| Quantity | Value | Unit | Note | +|---|---:|---|---| +| Depth of focus, MN76 f/6 (+/-) | 39.6 | um | 2*lambda*N^2 | +| Depth of focus, MN78 f/8 (+/-) | 70.4 | um | larger -> f/8 more forgiving | +| DoF ratio f8/f6 | 1.778 | x | ~(8/6)^2 = 1.78 (scales as N^2) | +| Tube length (MN78) | 1.4 | m | aluminium metering length (choice) | +| Aluminium CTE | 2.360e-05 | 1/K | 6061-T6 (params) | +| Tube defocus per K | 33.04 | um/K | CTE_al * L_tube | +| K to leave best focus | 2.131 | K | DoF / (defocus per K) | +| Diurnal swing | 22.22 | K | P.ENV.diurnal_swing_c | +| Tube defocus over swing | 734.2 | um | = 10.4 x DoF | +| Passive-focus SF | 0.09588 | x | K_to_edge / swing (<1 => fails) | +| Astrositall CTE | 1.500e-07 | 1/K | ~157x below aluminium | +| Mirror defocus per K | 0.216 | um/K | f * CTE_mirror | +| Mirror defocus over swing | 4.8 | um | negligible vs tube | +| Tube / mirror defocus ratio | 153 | x | aluminium dominates | +| POS modelled coeff (MN78) | 2 | um/K | vs first-principles bare-Al tube -> optimistic | +| Corrector area (from aperture) | 0.02545 | m^2 | pi*(D/2)^2 | +| Dew heater P (hold +5 K) | 1.272 | W | h=10 W/m^2K | +| All-sky ring reference | 2 | W | heater fits inside this | + +_Assumptions:_ +- Depth of focus DoF = 2*lambda*N^2 (one-sided, +/-), lambda = P.OPTICAL.wavelength_m (0.55 um); the full in-focus window is twice this. DoF scales as N^2 so f/8 > f/6. +- Tube-expansion defocus uses the TUBE LENGTH (P.OTA.tube_length_m) as the aluminium metering length, not the focal length: it is the physical aluminium between the primary cell and the focuser, and for these folded Mak-Newts (700/1400 mm tube vs 1068/1440 mm focal) it is the more physical and less alarming choice. DERIVED. +- Tube growth maps ~1:1 to defocus (no strong secondary magnification in a Mak-Newt); a catadioptric focus-amplification factor would make passive focus WORSE, so 1:1 is non-conservative-favourable and the FAIL verdict is robust. ASSUMED. +- Mirror term ~ f * CTE_mirror with CTE_mirror = P.OPTICAL.astrositall_cte (1.5e-7/K); system focal length used as an upper-bound proxy for the primary's focal length. DERIVED. +- Dew heater P = h*A*dT with h = 10 W/m^2K (still-air convection+radiation, ASSUMED), dT = 5 K above dewpoint (ASSUMED), A = corrector area from the clear aperture (meniscus is a few % larger -> mild under-estimate). +- All-sky dew ring reference = 2 W (stated reference figure, ASSUMED). +- Focuser temperature coefficient -2.5 steps/C is SOURCED from the repo focuser service (tests/unit/test_focuser_service.py default; docs/telescope_tools note ~2.5 steps/C) but is NOT in params.py; a focuser step size would be needed to convert steps -> microns and confirm the compensation resolves the tube term. NOT in params (see notes). +- FINDING: first-principles bare-6061-T6 defocus is ~33 um/K, whereas the POS retreat sim models MN78 at 2 um/K (pos/POS_RETREAT_SIMULATION.md TEMP_COEFFICIENT) -> the POS figure implies a low-CTE (carbon-fibre / compensated) tube or is ~16x optimistic for bare aluminium. SOURCED cross-check. +- CONCLUSION: passive focus FAILS the diurnal swing by ~10x; a temperature-compensated focuser (which the repo has) is REQUIRED, not optional. The Astrositall primary and a <2 W corrector heater are genuine wins that make the residual tractable once the tube term is compensated. + +### 10. Roll-off roof drive + snow load — ✅ PASS + +**Drive PASS: moving the 180 kg roof against rolling + the 35 mph close gust needs 235 N (11.8 Nm @ 5 cm wheel), SF 2.1 vs a ~500 N garage-door-class drive — and wind is 62% of that load, so the drive is WIND-sized. FINDING: the closed flat roof carries 10.8 kN of snow (~6.1x its dead weight) — the snow case is the roof's structural design driver; a sloped roof sheds it, and a snow-laden roof CANNOT be driven -> snow interlock required.** + +*Target:* Roof drive overcomes rolling + 35 mph gust with SF >= 2 and traverses within open_time +*Governing safety factor:* 2.12× + +| Quantity | Value | Unit | Note | +|---|---:|---|---| +| Roof mass | 180 | kg | ASSUMED (params) | +| Rolling resistance force | 88.26 | N | mu=0.05 * m * g | +| Wind drag @ 35 mph gust | 147.1 | N | 0.5*rho*V^2*Cd*A, Cd=1.3 | +| Frontal area (wind) | 0.9 | m^2 | span x 0.3 m fascia | +| Total tractive force | 235.3 | N | rolling + wind | +| Wind fraction of tractive force | 62.49 | % | drive is wind-sized | +| Drive torque @ wheel | 11.77 | Nm | F * 0.05 m | +| Drive capacity (reference) | 500 | N | garage-door-opener class, ASSUMED | +| Drive safety factor | 2.125 | x | capacity / tractive (pass >= 2) | +| Required roof speed | 0.06667 | m/s | 3 m / 45 s | +| Feasible speed ceiling | 0.3 | m/s | speed ok = True | +| Open time vs motor timeout | 45 | s | < 60 s timeout | +| Ground snow pressure | 1197 | Pa | 25 psf, ASSUMED (params) | +| Roof plan area | 9 | m^2 | span x length | +| Closed-roof snow load | 1.077e+04 | N | pressure x area | +| Snow mass on roof | 1099 | kg | ~1.1 tonne | +| Snow / roof dead weight | 6.103 | x | snow dominates the structure | +| Snow-laden rolling force | 626.9 | N | mu*(m_roof+m_snow)*g > drive | + +_Assumptions:_ +- Rolling resistance coefficient mu = 0.05 (ASSUMED): far above ideal steel-on-steel (~0.001-0.005) to cover V-groove wheels, misalignment, seal drag and grit — conservative. +- Drive-wheel radius 0.05 m (ASSUMED, stated). Drive torque = F * radius. +- Wind drag uses the 35 mph emergency-close gust (P.ENV.wind_gust_close_ms, SOURCED) on a frontal area = roof span x 0.3 m fascia height (ASSUMED) with a flat-panel Cd=1.3 (ASSUMED; ENV.cd_cylinder=1.1 is for a round tube). Air density 1.027 kg/m^3 at 1800 m (DERIVED). The result is sensitive to the assumed fascia height: a taller roof profile pushes the drive SF toward MARGINAL. +- Drive capacity 500 N is a residential garage-door-opener-class chain drive (ASSUMED reference); a commercial roof drive is stronger. FINDING: at 35 mph the WIND is 62% of the tractive load, so the drive is sized by wind, not roof weight — a positive close against the gust is the governing drive requirement. +- Required roof speed 0.067 m/s (3 m in 45 s) is well under the 0.30 m/s comfort/safety ceiling (ASSUMED), and open_time 45 s < 60 s motor timeout (SOURCED), so travel time is not the constraint. +- SNOW (structural): the closed flat roof carries the full ground snow load (25 psf, ASSUMED) = 10.8 kN over 9 m^2, ~6.1x the roof's own 1.8 kN dead weight. The snow case, not wind, is the roof panel/support design driver. A SLOPED, slippery roof sheds most of it (ASCE 7 slope factor Cs -> ~0 for a steep metal roof); a FLAT roll-off roof must carry all of it. +- SNOW (operational): a fully snow-laden roof needs 627 N to roll, which EXCEEDS the 500 N drive -> the roof must never be commanded open under snow. A snow/ice interlock (or a pitched shedding roof) is required; the safety monitor should hold the roof CLOSED while snow-loaded. +- OPEN ITEM: params.py specifies no roof structural section, so the snow load here is reported as the governing DESIGN LOAD, not certified against a computed roof capacity — see notes (recommend a POS Enclosure roof-structure entry). + +### 11. Night-time energy budget (UPS + solar autonomy) — ❌ FAIL + +**Night load ~287 W, 59% of it the ASSUMED 170 W DGX Spark. The 1500 VA/900 W UPS rides shutdown with 16x margin (~31 min) but only ~5% of a night. Off-grid AUTONOMY FAIL: the specified 400 W + 100 Ah pack gives only 3.3 h at 12 V (< 10 h night) — a 48 V pack gives 13.4 h — and the panel harvests ~1.4 kWh/day vs ~2.9 kWh needed. Drop/duty-cycle the DGX or go 48 V for true autonomy. (DGX dumps ~14 K into the enclosure at 2 ACH -> ventilate or locate it outside.)** + +*Target:* Solar+battery sustains the ~10 h winter night on the specified pack (SF = autonomy / night) +*Governing safety factor:* 0.33× + +| Quantity | Value | Unit | Note | +|---|---:|---|---| +| Mount (idle/track) | 36 | W | 12 V * 1.5 A * 2 axes | +| Mount (goto slew) | 48 | W | IGOTO 2.0 A (transient) | +| Camera | 36 | W | cooled CMOS + TEC, ASSUMED | +| Focuser | 5 | W | ASSUMED | +| Controllers / Pi | 15 | W | ASSUMED | +| Weather / sensors | 10 | W | ASSUMED | +| Network | 15 | W | ASSUMED | +| DGX Spark | 170 | W | ASSUMED (repo gives no wattage) | +| Night load (steady) | 287 | W | sum, mount idle | +| Night load (during goto) | 299 | W | mount at IGOTO | +| DGX share of night load | 59.23 | % | the load is DGX-dominated | +| UPS rating | 900 | W | 1500 VA, SOURCED spec | +| UPS usable energy | 150 | Wh | ~2x12V9Ah class, ASSUMED | +| UPS runtime @ night load | 31.36 | min | validates ~30 min spec | +| Shutdown time (park+close) | 2 | min | ASSUMED | +| UPS shutdown margin | 15.68 | x | usable / shutdown energy | +| UPS fraction of a night | 5.226 | % | of 10 h -> NOT overnight | +| Solar panel | 400 | W | SOURCED spec | +| Battery capacity | 100 | Ah | LiFePO4, SOURCED spec | +| Battery energy @ 12 V | 960 | Wh | 80% usable | +| Battery energy @ 48 V | 3840 | Wh | 4x -> voltage is decisive | +| Autonomy @ 12 V | 3.345 | h | < 10 h night -> FAILS | +| Autonomy @ 48 V | 13.38 | h | > 10 h night -> passes | +| Daily solar harvest (winter) | 1350 | Wh | 400W*4.5PSH*0.75 | +| Night energy need | 2870 | Wh | load * 10 h | +| Harvest / night need | 47.04 | % | < 100% -> not fully self-sustaining | +| Enclosure volume | 21.6 | m^3 | footprint x 2.4 m | +| DGX interior dT @ 2 ACH | 13.73 | K | forced ventilation needed | + +_Assumptions:_ +- Motor bus 12 V (ASSUMED; not in params) x IRUN 1.5 A / IGOTO 2.0 A (params) x 2 axes. V_bus*I over-estimates supply draw (the TMC driver chops the phase current, and DEC holds at reduced current), so the mount figure is conservative. +- Device wattages ASSUMED (repo gives none): camera 36 W (cooled CMOS+TEC), focuser 5 W, controllers/Pi 15 W, weather/sensors 10 W, network 15 W. These sum to ~117 W of non-DGX load. +- DGX Spark 170 W is ASSUMED — the README names 'NVIDIA DGX Spark' (Grace+Blackwell class) but states NO wattage. It is ~59% of the whole night load and therefore dominates the UPS, battery and thermal sizing. Every autonomy conclusion below hinges on this one assumed number. +- UPS 1500 VA / 900 W, ~30 min runtime (SOURCED spec, not in params). Usable energy 150 Wh (ASSUMED, ~2x12V9Ah at 80% DoD); cross-check: 150 Wh / 287 W ~ 31 min, consistent with the 30-min spec. Park+close+flush 2 min (ASSUMED) -> 16x shutdown margin, but the UPS holds only ~5% of a 10 h night: it is a graceful-shutdown store, not an overnight supply (matches power_manager: park at 50%, emergency close at 20%). +- Battery energy = 100 Ah x pack V x 80% usable. Pack voltage is UNSPECIFIED in the repo and DECISIVE: 12 V -> 1.2 kWh (~3.3 h autonomy, FAILS the night); 48 V -> 4.8 kWh (~13 h, passes). The headline verdict uses the lower, specified-by-default 12 V case — flag and resolve the pack voltage. +- Autonomy = usable Wh / night load; target = one 10 h winter imaging night (ASSUMED; astronomical dark is ~14 h in December, so 10 h is a generous target). SF = autonomy / night; pass at 1.0. At 12 V SF = 0.33 -> FAIL. +- Winter solar harvest = 400 W x 4.5 PSH x 0.75 derate = 1350 Wh/day (all ASSUMED), vs 2870 Wh/night -> harvest is only ~47% of a night's energy, so even a bigger battery drains over successive nights without grid. The 400 W array cannot sustain the DGX-dominated load off-grid; duty-cycling the DGX (run inference on demand, not idle all night) is the highest-leverage fix. +- DGX thermal: 170 W into a 22 m^3 enclosure (footprint x 2.4 m, ASSUMED) at 2 natural ACH (ASSUMED) gives dT = Q/(rho*cp*Vdot) ~ 14 K rise — enough to spoil local seeing and drive dew. Force ~10 ACH of ventilation, or locate the DGX OUTSIDE the optical enclosure. cp_air 1005 J/kg-K, rho 1.027 kg/m^3 at 1800 m (DERIVED). +- CONCLUSION (open question resolved): graceful shutdown is comfortably covered; full off-grid autonomy on the specified 12 V / 400 W system is NOT — the assumed 170 W DGX Spark is the reason. Specify the pack at 48 V and/or duty-cycle the DGX to make a winter night off-grid feasible. + +## 6. Provenance ledger + +Where each key input comes from — the honesty table the repo lacks. + +| Parameter | Provenance | Source / basis | +|---|:--:|---| +| `MN76.mass_kg` | **S** | NIGHTWATCH_Build_Package.md spec table | +| `MN78.mass_kg` | **S** | docs/INTES_MICRO_HISTORY.md ('selected') | +| `OTA.cg_from_saddle_m` | **D** | geometric mid-tube estimate | +| `IMAGING_TRAIN.mass_kg` | **S** | pos/agents/05_walton_musser.md load analysis | +| `RA_DRIVE` | **S** | Build_Package.md / Musser (CSF-32-100) | +| `DEC_DRIVE` | **S** | Build_Package.md / Musser (CSF-25-80) | +| `MOTOR.slew_rate_dps` | **S** | firmware/onstepx_config/Config.h | +| `ENC_MOTOR_AMT103` | **S** | Build_Package.md / Hedrick (8192 PPR x100) | +| `ENC_AXIS_AS5600` | **S** | Build_Package.md / Hedrick (12-bit on-axis) | +| `ENC_AXIS_RESA` | **A** | proposed on-axis ring to meet sub-arcsec | +| `RA_HOUSING.wall_m` | **S** | Hedrick min 8 mm (12 mm bold review) | +| `BRG_RA_6008` | **S** | Build_Package.md bearing table (deep-groove C ratings) | +| `COUNTERWEIGHTS` | **S** | Build_Package.md counterweight table | +| `PIER` | **S** | Build_Package.md concrete pier spec | +| `ENCLOSURE.roof_mass_kg` | **A** | roof geometry unspecified in repo | +| `SITE` | **S** | config.py / Config.h (central Nevada, 1800 m) | +| `SITE.air_density` | **D** | ISA at 1800 m | +| `ENV.wind_park_ms` | **S** | services/safety_monitor/monitor.py | +| `ENV.survival_wind_ms` | **A** | ASCE 7 basic wind speed, central NV | +| `ENV.ground_snow_load_Pa` | **A** | high-desert @ 6000 ft | +| `ENV.seismic_sds_g` | **A** | Walker Lane seismicity | +| `TRACKING_RMS_TARGET_ARCSEC` | **S** | Hedrick '< 1 arcsec RMS' | +| `DEFLECTION_TARGET_ARCSEC` | **S** | Hedrick '< 5 arcsec @ 25 kg' | +| `NATURAL_FREQ_TARGET_HZ` | **S** | Hedrick '> 10 Hz' | + +**S** = sourced (repo file) · **D** = derived (computed) · **A** = assumed (repo silent). + +## 7. Trade Study (Phase B) — Morphological Box + Weighted-Pugh Selection + +This section does not compute physics; it **selects** among design permutations by +scoring them against the verdicts the Phase-C proof modules actually computed, so +the configuration that goes into CAD is grounded rather than asserted. The scoring +matrix, the three CSVs below, and their invariants are a pure function encoded in +`design/mechanical/tradestudy/build_tradestudy.py` and locked by +`design/mechanical/tests/test_tradestudy.py` (10 tests, green). + +**Grounding — the Phase-C verdict ledger the scores are anchored to:** + +| Proof | Verdict | Governing number | +|---|---|---| +| torque | PASS | counterweight-FREE MN78: RA SF 2.5, DEC SF 2.6 vs rated | +| stiffness | **FAIL** | 43.3" vs 5" target — bearing compliance at 63.5/76.2 mm spans governs | +| dynamics | PASS | first mode 65 Hz vs 10 Hz (SF 6.5) | +| encoder | **FAIL** | baseline 5.02" RMS vs 1.0"; on-axis RESA ring 0.54" | +| balance | PASS | 12.5 kg balances 18 kg (fit 1.59); CW-free deletes 15.4 kg + 29% RA inertia | +| bearings | PASS + finding | load never governs; 60xx are deep-groove — use angular-contact 7008/7006 pairs | +| wind | **FAIL** | roof uplift SF 0.19 — hold-down anchors mandatory (SF 3.9 with 4x 2 klbf) | +| pier | PASS | governing SF 6.8, f_n 152 Hz | +| thermal | **FAIL** (passive) | 22 K swing walks focus ~10x DoF — temp-compensated focuser required | +| enclosure | PASS + finding | drive SF 2.1; snow case governs the roof, snow interlock required | +| power | **FAIL** (off-grid) | 12 V pack 3.3 h vs 10 h night; 48 V -> 13.4 h; DGX = 59% of load | + +**Scoring convention.** Every criterion is *higher-is-better* on a 1–5 scale (for +cost and sourcing, 5 = cheapest / lowest risk), so a weighted score stays in 1–5. +Default weights: **stiffness 0.22, precision 0.22, cost 0.15, buildability 0.15, +thermal 0.10, sourcing-risk 0.16**. Crucially, precision against the hard 1.0" +tracking target is a **pass/fail GATE**, not merely a weighted term: an option that +fails it is *disqualified* regardless of its weighted score. + +### 1. Morphological (Zwicky) matrix — the option space + +`morphological.csv`. Bold picks are marked **[BOLD]**. + +| Subsystem | Baseline | Alternatives | Bold | +|---|---|---|---| +| OTA | MN78 f/8 (14 kg) | MN76 f/6 (9 kg), MN86 8", ES-MN152 | APM-LZOS apo triplet | +| Mount topology | Counterweighted GEM | Fork + derotator | **Counterweight-FREE GEM** | +| Axis drive | NEMA17 + 27:1 + harmonic | Direct-to-harmonic | **Torque-motor direct drive** | +| Encoder | AMT103 + AS5600 | Hybrid (motor + on-axis abs.) | **On-axis high-res absolute ring** | +| Pier | Concrete Sonotube | Steel-concrete hybrid | **Isolated pier-in-pier** | +| Enclosure | Roll-off roof | Clamshell dome | **Roll-off + active thermal** | +| Frame | 6061-T6 CNC plates | Steel weldment | **Cast housings** | + +### 2. Weighted-Pugh scores (default weights) + +`pugh_scores.csv`. Winner of each subsystem in **bold**; the encoder baseline is +`DISQUALIFIED` by the tracking gate. + +| Subsystem | Option | stiff | prec | cost | build | therm | src | **Weighted** | +|---|---|:-:|:-:|:-:|:-:|:-:|:-:|:-:| +| OTA | MN78 f/8 (baseline) | 2 | 4 | 3 | 3 | 4 | 2 | 2.94 | +| OTA | MN76 f/6 | 4 | 3 | 3 | 4 | 3 | 2 | 3.21 | +| OTA | MN86 8" | 1 | 4 | 2 | 2 | 3 | 1 | 2.16 | +| OTA | APM-LZOS **[BOLD]** | 2 | 5 | 1 | 3 | 3 | 2 | 2.76 | +| OTA | **ES-MN152** | 4 | 3 | 5 | 4 | 3 | 5 | **3.99** | +| Topology | Counterweighted GEM (baseline) | 3 | 3 | 3 | 4 | 3 | 4 | 3.31 | +| Topology | **Counterweight-FREE GEM [BOLD]** | 3 | 4 | 4 | 4 | 3 | 4 | **3.68** | +| Topology | Fork + derotator | 3 | 2 | 2 | 2 | 3 | 2 | 2.32 | +| Drive | **NEMA17 + 27:1 + harmonic (baseline)** | 3 | 2 | 4 | 4 | 3 | 4 | **3.24** | +| Drive | Direct-to-harmonic | 3 | 3 | 3 | 3 | 3 | 4 | 3.16 | +| Drive | Torque-motor DD **[BOLD]** | 3 | 5 | 1 | 2 | 2 | 2 | 2.73 | +| Encoder | AMT103 + AS5600 (baseline) | 3 | 1 | 5 | 4 | 3 | 5 | 3.33 `DISQ` | +| Encoder | On-axis RESA ring **[BOLD]** | 3 | 5 | 1 | 2 | 3 | 2 | 2.83 | +| Encoder | **Hybrid (motor + on-axis abs.)** | 3 | 4 | 2 | 3 | 3 | 3 | **3.07** | +| Pier | **Concrete Sonotube (baseline)** | 4 | 3 | 5 | 4 | 4 | 5 | **4.09** | +| Pier | Isolated pier-in-pier **[BOLD]** | 4 | 4 | 3 | 3 | 4 | 4 | 3.70 | +| Pier | Steel-concrete hybrid | 3 | 3 | 3 | 3 | 2 | 3 | 2.90 | +| Enclosure | **Roll-off roof (baseline)** | 3 | 3 | 5 | 5 | 3 | 5 | **3.92** | +| Enclosure | Clamshell dome | 3 | 3 | 2 | 2 | 2 | 2 | 2.44 | +| Enclosure | Roll-off + active thermal **[BOLD]** | 3 | 4 | 4 | 4 | 5 | 4 | 3.88 | +| Frame | **6061-T6 CNC plates (baseline)** | 3 | 3 | 4 | 4 | 4 | 4 | **3.56** | +| Frame | Steel weldment | 4 | 3 | 3 | 2 | 3 | 3 | 3.07 | +| Frame | Cast housings **[BOLD]** | 4 | 4 | 2 | 1 | 3 | 2 | 2.83 | + +Two results are worth pausing on. First, the **encoder baseline has the *highest* +raw weighted score in its subsystem (3.33) yet is not selectable** — it fails the +1.0" gate (encoder proof: 5.02" RMS). A naive weighted sum would have kept a design +that misses the headline requirement by 5x; the gate is what prevents that, and it +is the single most important structural feature of this study. Second, the **OTA +math prefers the light in-production ES-MN152 (3.99), and among the Intes pair MN76 +(3.21) out-scores the doc-'selected' MN78 (2.94)** — MN78 is the *worst* realistic +tube on the six mechanical axes, precisely because its 14 kg / 1.4 m tube is the +lever the stiffness proof FAILs on. + +### 3. Sensitivity — precision-heavy and cost-heavy re-weighting + +`sensitivity.csv`. Precision-heavy = {prec 0.40, stiff 0.20, cost 0.10, build 0.10, +therm 0.08, src 0.12}; cost-heavy = {cost 0.35, src 0.20, build 0.15, stiff 0.12, +prec 0.10, therm 0.08}. + +| Subsystem | Default winner | Precision-heavy | Cost-heavy | Robust? | +|---|---|---|---|---| +| OTA | ES-MN152 (3.99) | ES-MN152 (3.74) | ES-MN152 (4.37) | **stable** | +| Topology | CW-FREE GEM (3.68) | CW-FREE GEM (3.72) | CW-FREE GEM (3.80) | **stable** | +| Drive | NEMA17+harmonic (3.24) | **Torque-motor DD (3.30)** | NEMA17+harmonic (3.60) | flips (prec) | +| Encoder | Hybrid (3.07) | **RESA ring (3.38)** | Hybrid (2.75) | flips (prec) | +| Pier | Sonotube (4.09) | Sonotube (3.82) | Sonotube (4.45) | **stable** | +| Enclosure | Roll-off (3.92) | **Roll-off + thermal (3.88)** | Roll-off (4.40) | flips (prec) | +| Frame | 6061 CNC (3.56) | 6061 CNC (3.40) | 6061 CNC (3.56/3.78) | **stable** | + +The winner changes in exactly three subsystems, and **all three flips are in the +same direction**: weighting the sub-arcsec mission more heavily pulls the drive to a +zero-gear-PE torque motor, the encoder to the full on-axis ring, and the enclosure +to active thermal control — i.e. the bold options are the *precision* options, and +they win the moment precision dominates. Four subsystems (OTA, topology, pier, +frame) never move. + +### 4. Selected permutation + rationale + +The delivered build takes the default-weight winners, with two deliberate, +disclosed departures. It comprises **4 baselines, 2 bold picks, and 1 mandatory +bold-adjacent upgrade**: + +1. **OTA — MN78 f/8 (science override of the mechanical winner).** The mechanical + Pugh winner is ES-MN152 under every weighting, and MN76 beats MN78 among the + Intes pair. MN78 is retained for its f/8 imaging scale and 0.134 obstruction — + an optical/science requirement that lives *outside* the six mechanical criteria. + This retention is not free: it is exactly why the stiffness proof FAILs, and it + is the reason item 8 below is mandatory. If the bearing remediation proves + insufficient, **MN76 is the drop-in mechanical hedge** (shorter CG lever, lower + RA torque). +2. **Mount topology — Counterweight-FREE GEM [BOLD].** Robust winner under all three + weightings. Grounded: torque proof gives RA SF 2.5 / DEC SF 2.6 on the harmonic + drives' back-drive resistance, and the balance proof shows deleting the shaft + + weights removes **15.4 kg and 29% of the RA inertia** — a strict improvement in + what the drives and pier must carry, the same principle the ZWO AM5 / RST-135 + exploit. +3. **Axis drive — NEMA17 + 27:1 planetary + harmonic (baseline).** Wins default and + cost-heavy; torque proof PASS. The planetary's periodic error — the only reason + to consider deleting it — is **mooted by the on-axis encoder (item 4), which + corrects everything upstream of the axis**, so the cheap standard OnStepX + drivetrain is retained. (Under a precision-only view it flips to the torque + motor; that is a documented, deferred upgrade path.) +4. **Encoder — Hybrid: motor encoder (velocity) + on-axis absolute (position) + [mandatory on-axis upgrade].** The as-specified baseline is **DISQUALIFIED** — + encoder proof: 5.02" RMS, 5x over target, because the AS5600's ~91" quantisation + makes it homing-grade only and the servo must close on the motor encoder, which + cannot see harmonic PE or mount flexure. Some form of on-axis absolute feedback + is *required*, not optional (proof: on-axis ring reaches 0.54" RMS). The hybrid + wins among qualifying options at default/cost weights; the **full on-axis RESA + ring [BOLD]** is the selected upgrade when precision is weighted heavily and is + the only option that reaches sub-arcsec outright. +5. **Pier — Concrete Sonotube (baseline).** Wins under all weightings (4.09). The + pier proof already passes with governing SF 6.8 and f_n 152 Hz, so the bold + isolated pier-in-pier would be **gold-plating the one part that is not the + constraint** — the head/bearings are. Retained as baseline. +6. **Enclosure — Roll-off + active thermal [BOLD].** A statistical tie with the + plain roll-off at default weights (3.88 vs 3.92, within noise), broken toward the + bold variant by two proof findings the plain roof ignores: the **thermal FAIL** + (22 K diurnal swing walks focus ~10x depth-of-focus) and the **power finding** + that the DGX dumps ~14 K into the enclosure at 2 ACH ("ventilate or locate + outside"). Active ventilation + insulation + day pre-cooling is the direct remedy, + and it becomes the outright winner the instant precision is weighted up. +7. **Frame — 6061-T6 CNC plates (baseline).** Wins under all weightings (3.56). The + key insight from the stiffness proof: the **aluminium plates are *not* the + governing compliance — the bearings are** — so switching to a steel weldment or + cast housings spends stiffness budget in the wrong place. Retain the machinable, + DIY-friendly 6061 frame and fix the bearings instead (item 8). + +**Cross-cutting actions the trade study surfaces but cannot select away.** Three +Phase-C FAILs are not closable by any of the seven morphological axes and are +booked here as required detail-design actions: + +- **Stiffness (governing FAIL, 43.3" vs 5"):** fix at the *component* level, not the + configuration level — replace the deep-groove 6008/6006 with **matched + angular-contact 7008/7006 pairs (back-to-back) on larger spans** (bearings + + stiffness proofs). No frame or topology choice in the box moves this number. +- **Wind (FAIL, roof uplift SF 0.19):** hold-down anchors are **mandatory and + currently unspecified** — 4x 2 klbf anchors restore SF 3.9 (wind proof). Pair with + the snow interlock the enclosure proof requires. +- **Off-grid power (FAIL):** the selected build assumes grid + UPS (16x shutdown + margin). True autonomy needs a **48 V pack (13.4 h) or a DGX duty-cycle**, not the + specified 12 V / 100 Ah pack (3.3 h). Orthogonal to the enclosure/pier choices. + +**Net:** the selected permutation banks the two clearly-won bold improvements +(counterweight-free GEM, active-thermal enclosure) plus the mandatory on-axis +encoder, keeps the baseline where the baseline already passes (drive, pier, frame), +and honestly flags that the OTA choice, the stiffness fix, the wind anchors, and the +power autonomy are decisions the science mission and detail design must own — the +trade study cannot make them disappear. + +## 8. Mechanical CAD (Phase D) — parametric geometry + +The repository shipped **zero usable CAD**: every `.step` reference in the build package is marked *Pending*. Phase D replaces that void with **text-based, parametric geometry driven entirely by the computed design** in `design/mechanical/calc/params.py`. Nothing is hand-dimensioned that already exists as a parameter — change a number in `params.py` and the models (and the preview) move with it. + +### The immediate visual + +![NIGHTWATCH GEM meridian elevation](cad/preview_assembly.svg) + +`cad/preview_assembly.svg` is a to-scale meridian-plane side elevation of the mount in its park pose — pier, RA housing on the polar axis inclined at the **38.9° site latitude**, DEC head, MN78 OTA pointing at the pole, and the counterweight shaft hanging on the down side. It is produced by a **pure-stdlib** Python script (no OpenSCAD, no third-party libraries) that imports `params.py` and writes the SVG with the real numbers annotated. This is the deliverable you can look at right now; the OpenSCAD files below are the manufacturable source. + +### The parts + +| File | Part | Headline dimensions (all traced to `params.py`) | +|---|---|---| +| `cad/ra_housing.scad` | RA (polar) axis housing | 203.2 × 203.2 × 76.2 mm block (`RA_HOUSING` 8″×8″×3″), 8 mm wall; stepped axial bore — 6008 seat (Ø68/Ø40, `BRG_RA_6008`) opening to the CSF‑32 Ø80 hollow‑drive register (`RA_DRIVE.bore_m`); flange bolt circle ≈ Ø104 | +| `cad/dec_housing.scad` | DEC axis housing + **Losmandy‑D saddle** | 152.4 × 152.4 × 63.5 mm (`DEC_HOUSING` 6″×6″×2.5″); 6006 seat (Ø55/Ø30, `BRG_DEC_6006`) → CSF‑25 Ø64 register (`DEC_DRIVE.bore_m`); dovetail saddle 76.2 mm / 15° with side clamp | +| `cad/pier_adapter.scad` | Pier adapter plate | 254 × 254 × 19.05 mm (10″×10″×0.75″) bridging the SOURCED 304.8 × 9.525 mm (12″×0.375″) `PIER` top plate to the RA housing corner pattern | +| `cad/counterweight_shaft.scad` | Counterweight shaft + weights | Ø31.75 mm × 457.2 mm 303‑SS (`COUNTERWEIGHTS` 1.25″×18″), stud + safety knob, 5+5+2.5 kg discs (12.5 kg available) | +| `cad/assembly.scad` | Full GEM assembly | Composes all four parts + the MN78 OTA tube (Ø210 × 1400 mm) on the pier, posed at latitude `SITE.latitude_deg` | +| `cad/svg_preview.py` | Zero‑dep previewer | Imports `params`/`units`, emits `preview_assembly.svg` | + +### Parametric approach + +Each `.scad` opens with a **params block** whose every entry carries a `// = P.<...>` comment tracing it to `params.py` (SI metres in the calculator → millimetres in OpenSCAD, the CAD convention). Parts are pure `module`s; `assembly.scad` pulls them in with `use <...>` and places them with explicit transforms (`rotate([0, 90-lat, 0])` puts local *+z* on the celestial pole; a nested `rotate([90,0,0])` sets the DEC axis perpendicular to RA). The SVG previewer is the strictest link in the chain — it reads `params.py` **at runtime**, so it can never disagree with the proofs, and a test pins the committed SVG byte‑for‑byte to the renderer output. + +### How to render + +OpenSCAD is **not installed** in the design sandbox, so each part header documents the command to run locally: + +``` +openscad -o ra_housing.stl design/mechanical/cad/ra_housing.scad +openscad -o assembly.stl design/mechanical/cad/assembly.scad +openscad -o assembly.png --imgsize=1600,1200 design/mechanical/cad/assembly.scad +``` + +The SVG needs nothing but Python: `python3 design/mechanical/cad/svg_preview.py` regenerates `preview_assembly.svg` (re‑run it whenever `params.py` changes). + +### Honest assumptions (tagged ASSUMED/DERIVED in‑file and in `svg_preview`) + +- **Pier adapter (10×10×0.75″)** is an *added* part — the repo's `PIER` only specifies a 12×12×0.375″ top plate, so the adapter geometry is **ASSUMED**. +- **Losmandy‑D dovetail** 76.2 mm / 15° is the **ASSUMED** industry standard; the repo never dimensions the saddle. +- **Bearing widths** (6008 = 15 mm, 6006 = 13 mm) and **counterweight disc OD/thickness** are **ASSUMED** — `params` gives bore/OD and weight mass only. +- **CSF flange bolt circle** is taken as **DERIVED** ≈ 1.3 × drive bore (`RA_DRIVE.bore_m` / `DEC_DRIVE.bore_m`), since datasheet PCDs are not in the repo. + +### Verification + +`design/mechanical/tests/test_cad.py` — **13 tests, all green** (and the full mechanical suite stays at 118 passed): the polar‑axis vector is a unit vector at the site latitude with the perpendiculars orthogonal to it; drawn dimensions equal `params.py` exactly; swapping MN76↔MN78 changes the geometry (proof it is parametric); the head sits above the pier top, the OTA clears the DEC head toward the pole, and the counterweight hangs on the opposite side of the inclined axis; the SVG is valid, dimensioned with the real numbers, and actually writes; and every `.scad` carries its render command and `params.py` traceability. + +## 9. Selected configuration, cost delta, and roadmap + +The trade study's selected permutation, with the cost delta against the repo's **$8,305** BOM +(DGX excluded) and a build sequencing that ships a working v1 before the sub-arcsec v2. + +| Subsystem | Selected | Why | Cost delta | +|---|---|---|---| +| OTA | **MN78 f/8** (science override) | f/8 scale + 0.134 obstruction for Mars/lucky imaging; MN76 is the mechanical hedge | baseline | +| Topology | **Counterweight-FREE GEM** (bold) | torque proof SF 2.5/2.6; deletes 15.4 kg + 29% RA inertia | −$110 (no CW set) | +| Drive | NEMA17 + 27:1 + harmonic | PASS; planetary PE mooted by on-axis encoder | baseline | +| Encoder | **Hybrid → on-axis absolute** (mandatory) | baseline DISQUALIFIED (5.02″ RMS); on-axis ring 0.54″ | +$1,500–5,000 | +| Bearings | **Angular-contact 7008/7006** (fix) | deep-groove 6008/6006 are 98% of the stiffness FAIL | +$80 | +| Pier | Concrete Sonotube | PASS SF 6.8; not the constraint | baseline | +| Enclosure | **Roll-off + active thermal** (bold) | closes thermal FAIL + DGX heat | +$300 | +| Anchors | **4× 2 klbf hold-downs** (fix) | survival-wind uplift SF 0.19 → 3.9 | +$120 | +| Power | Grid + UPS (v1); **48 V pack** for autonomy | 12 V pack gives 3.3 h vs 10 h night | +$400 (v2) | + +**Roadmap.** +- **v1 (buildable now):** MN78 counterweight-free GEM on the concrete pier, roll-off roof with the + mandatory hold-down anchors and snow interlock, angular-contact bearings, grid + UPS power, + temperature-compensated focus. Meets every structural and thermal requirement; tracks to ~5″ + (motor-encoder + PE), which is fine for the wide-field/lucky-imaging use with plate-solve recentering. +- **v2 (sub-arcsecond):** add the on-axis absolute ring (closes the 1″ tracking target), active-thermal + enclosure, and — if precision is weighted above cost — the torque-motor direct drive. Off-grid + autonomy via the 48 V battery + duty-cycled DGX. + +## 10. Risk register + +| Risk | Severity | Mitigation (computed) | +|---|---|---| +| Frame stiffness FAIL (43″) | High | Angular-contact 7008/7006 back-to-back on max spans; verify with the stiffness proof once bearing stiffness is vendor-confirmed | +| Sub-arcsec unmet by baseline encoder | High | On-axis absolute ring (proof: 0.54″); v1 relies on plate-solve recentering | +| Survival-wind roof uplift (SF 0.19) | High | 4× 2 klbf hold-down anchors (SF 3.9) + snow interlock | +| Passive focus drift over diurnal swing | Medium | Temperature-compensated focuser (repo already has −2.5 steps/°C) | +| Off-grid autonomy shortfall | Medium | 48 V pack (13.4 h) or DGX duty-cycle; v1 stays on grid + UPS | +| Bearing-stiffness assumption (250/180 N/µm) | Medium | Confirm vendor radial stiffness; it scales the deflection linearly | +| DGX watts/heat unspecified (assumed 170 W) | Low | Ventilate or locate the DGX outside the OTA enclosure | + +--- + +_Generated by `design/mechanical/calc/report.py` from the proof modules. Regenerate with +`python3 -m design.mechanical.calc.report`; `test_report.py` guards against drift._ diff --git a/design/mechanical/cad/SECTION.md b/design/mechanical/cad/SECTION.md new file mode 100644 index 0000000..8c505ae --- /dev/null +++ b/design/mechanical/cad/SECTION.md @@ -0,0 +1,47 @@ +## Mechanical CAD (Phase D) — parametric geometry + +The repository shipped **zero usable CAD**: every `.step` reference in the build package is marked *Pending*. Phase D replaces that void with **text-based, parametric geometry driven entirely by the computed design** in `design/mechanical/calc/params.py`. Nothing is hand-dimensioned that already exists as a parameter — change a number in `params.py` and the models (and the preview) move with it. + +### The immediate visual + +![NIGHTWATCH GEM meridian elevation](cad/preview_assembly.svg) + +`cad/preview_assembly.svg` is a to-scale meridian-plane side elevation of the mount in its park pose — pier, RA housing on the polar axis inclined at the **38.9° site latitude**, DEC head, MN78 OTA pointing at the pole, and the counterweight shaft hanging on the down side. It is produced by a **pure-stdlib** Python script (no OpenSCAD, no third-party libraries) that imports `params.py` and writes the SVG with the real numbers annotated. This is the deliverable you can look at right now; the OpenSCAD files below are the manufacturable source. + +### The parts + +| File | Part | Headline dimensions (all traced to `params.py`) | +|---|---|---| +| `cad/ra_housing.scad` | RA (polar) axis housing | 203.2 × 203.2 × 76.2 mm block (`RA_HOUSING` 8″×8″×3″), 8 mm wall; stepped axial bore — 6008 seat (Ø68/Ø40, `BRG_RA_6008`) opening to the CSF‑32 Ø80 hollow‑drive register (`RA_DRIVE.bore_m`); flange bolt circle ≈ Ø104 | +| `cad/dec_housing.scad` | DEC axis housing + **Losmandy‑D saddle** | 152.4 × 152.4 × 63.5 mm (`DEC_HOUSING` 6″×6″×2.5″); 6006 seat (Ø55/Ø30, `BRG_DEC_6006`) → CSF‑25 Ø64 register (`DEC_DRIVE.bore_m`); dovetail saddle 76.2 mm / 15° with side clamp | +| `cad/pier_adapter.scad` | Pier adapter plate | 254 × 254 × 19.05 mm (10″×10″×0.75″) bridging the SOURCED 304.8 × 9.525 mm (12″×0.375″) `PIER` top plate to the RA housing corner pattern | +| `cad/counterweight_shaft.scad` | Counterweight shaft + weights | Ø31.75 mm × 457.2 mm 303‑SS (`COUNTERWEIGHTS` 1.25″×18″), stud + safety knob, 5+5+2.5 kg discs (12.5 kg available) | +| `cad/assembly.scad` | Full GEM assembly | Composes all four parts + the MN78 OTA tube (Ø210 × 1400 mm) on the pier, posed at latitude `SITE.latitude_deg` | +| `cad/svg_preview.py` | Zero‑dep previewer | Imports `params`/`units`, emits `preview_assembly.svg` | + +### Parametric approach + +Each `.scad` opens with a **params block** whose every entry carries a `// = P.<...>` comment tracing it to `params.py` (SI metres in the calculator → millimetres in OpenSCAD, the CAD convention). Parts are pure `module`s; `assembly.scad` pulls them in with `use <...>` and places them with explicit transforms (`rotate([0, 90-lat, 0])` puts local *+z* on the celestial pole; a nested `rotate([90,0,0])` sets the DEC axis perpendicular to RA). The SVG previewer is the strictest link in the chain — it reads `params.py` **at runtime**, so it can never disagree with the proofs, and a test pins the committed SVG byte‑for‑byte to the renderer output. + +### How to render + +OpenSCAD is **not installed** in the design sandbox, so each part header documents the command to run locally: + +``` +openscad -o ra_housing.stl design/mechanical/cad/ra_housing.scad +openscad -o assembly.stl design/mechanical/cad/assembly.scad +openscad -o assembly.png --imgsize=1600,1200 design/mechanical/cad/assembly.scad +``` + +The SVG needs nothing but Python: `python3 design/mechanical/cad/svg_preview.py` regenerates `preview_assembly.svg` (re‑run it whenever `params.py` changes). + +### Honest assumptions (tagged ASSUMED/DERIVED in‑file and in `svg_preview`) + +- **Pier adapter (10×10×0.75″)** is an *added* part — the repo's `PIER` only specifies a 12×12×0.375″ top plate, so the adapter geometry is **ASSUMED**. +- **Losmandy‑D dovetail** 76.2 mm / 15° is the **ASSUMED** industry standard; the repo never dimensions the saddle. +- **Bearing widths** (6008 = 15 mm, 6006 = 13 mm) and **counterweight disc OD/thickness** are **ASSUMED** — `params` gives bore/OD and weight mass only. +- **CSF flange bolt circle** is taken as **DERIVED** ≈ 1.3 × drive bore (`RA_DRIVE.bore_m` / `DEC_DRIVE.bore_m`), since datasheet PCDs are not in the repo. + +### Verification + +`design/mechanical/tests/test_cad.py` — **13 tests, all green** (and the full mechanical suite stays at 118 passed): the polar‑axis vector is a unit vector at the site latitude with the perpendiculars orthogonal to it; drawn dimensions equal `params.py` exactly; swapping MN76↔MN78 changes the geometry (proof it is parametric); the head sits above the pier top, the OTA clears the DEC head toward the pole, and the counterweight hangs on the opposite side of the inclined axis; the SVG is valid, dimensioned with the real numbers, and actually writes; and every `.scad` carries its render command and `params.py` traceability. \ No newline at end of file diff --git a/design/mechanical/cad/assembly.scad b/design/mechanical/cad/assembly.scad new file mode 100644 index 0000000..f557d91 --- /dev/null +++ b/design/mechanical/cad/assembly.scad @@ -0,0 +1,83 @@ +// ============================================================================ +// NIGHTWATCH GEM — full mount assembly +// Composes the parametric parts into a German Equatorial Mount on its pier, +// posed in the classic "counterweight-down / OTA-to-pole" park. +// Parametric OpenSCAD model. Units: MILLIMETRES. +// +// Render (OpenSCAD not installed here): +// openscad -o assembly.stl assembly.scad +// openscad -o assembly.png --imgsize=1600,1200 assembly.scad +// +// The immediate, dependency-free visual is design/mechanical/cad/preview_assembly.svg +// (generated by svg_preview.py, which reads the SAME params.py numbers). +// +// Dimensions trace to design/mechanical/calc/params.py (SI metres -> mm). +// ============================================================================ + +use +use +use +use + +$fn = 64; + +// ---- parameters (traced to params.py) -------------------------------------- +lat = 38.9; // P.SITE.latitude_deg -> polar-axis altitude +pier_h = 914.4; // P.PIER.height_above_m = u.inch(36) +pier_d = 304.8; // P.PIER.diameter_m = u.inch(12) +tp_side = 304.8; // P.PIER.top_plate_side_m = u.inch(12) +tp_thk = 9.525; // P.PIER.top_plate_thk_m = u.inch(0.375) +adapter_thk = 19.05; // pier_adapter.scad (0.75 in), ASSUMED added part + +ra_depth = 76.2; // P.RA_HOUSING.depth_m = u.inch(3) +dec_face = 152.4; // P.DEC_HOUSING.outer_x_m = u.inch(6) +dec_depth = 63.5; // P.DEC_HOUSING.depth_m = u.inch(2.5) + +// Default OTA = MN78 (the params default across the torque/balance/pier proofs). +ota_od = 210.0; // P.MN78.tube_od_m = u.mm(210) +ota_len = 1400.0; // P.MN78.tube_length_m = u.mm(1400) +saddle_stack = 50.0; // stiffness.SADDLE_LEVER_M (assumed saddle offset) + +// Derived placements +adapter_top = pier_h + tp_thk + adapter_thk; +L_ra = ra_depth + dec_face/2 + 40; // DEC-head seat along the polar axis (DERIVED) +ota_offset = dec_face/2 + saddle_stack + ota_od/2; // saddle-side radial offset + +// ---- assembly -------------------------------------------------------------- +module gem_assembly() { + // Concrete pier (exposed height only) + color([0.62, 0.62, 0.60]) cylinder(d = pier_d, h = pier_h); + + // Steel top plate (SOURCED, P.PIER) + color([0.30, 0.30, 0.32]) + translate([-tp_side/2, -tp_side/2, pier_h]) cube([tp_side, tp_side, tp_thk]); + + // Pier adapter plate (10x10x0.75 in, ASSUMED added part) + color([0.80, 0.67, 0.24]) + translate([0, 0, pier_h + tp_thk]) pier_adapter(); + + // ---- mount head, mounted on the polar (RA) axis ---- + translate([0, 0, adapter_top]) + rotate([0, 90 - lat, 0]) { // local +z -> celestial pole (alt = lat) + + color([0.75, 0.78, 0.80]) ra_housing(); + + // DEC head at the far end of the RA axis; DEC axis _|_ RA axis + translate([0, 0, L_ra]) { + + rotate([90, 0, 0]) color([0.70, 0.73, 0.76]) dec_housing(); + + // Counterweight shaft on the "down" side (classical GEM option; + // balance.py recommends deleting it — drawn for completeness). + translate([0, dec_face/2, 0]) + rotate([-90, 0, 0]) color([0.20, 0.20, 0.22]) counterweight_shaft(); + + // OTA tube, parallel to the polar axis, on the saddle side. + // Saddle sits ~1/3 up the tube (matches OTA.cg_from_saddle model). + translate([0, -ota_offset, -ota_len/3]) + color([0.10, 0.10, 0.12]) cylinder(d = ota_od, h = ota_len); + } + } +} + +gem_assembly(); diff --git a/design/mechanical/cad/counterweight_shaft.scad b/design/mechanical/cad/counterweight_shaft.scad new file mode 100644 index 0000000..058da3d --- /dev/null +++ b/design/mechanical/cad/counterweight_shaft.scad @@ -0,0 +1,56 @@ +// ============================================================================ +// NIGHTWATCH GEM — counterweight shaft + weights +// Parametric OpenSCAD model. Units: MILLIMETRES. +// +// Render (OpenSCAD not installed here): +// openscad -o counterweight_shaft.stl counterweight_shaft.scad +// openscad -o counterweight_shaft.png --imgsize=1200,900 counterweight_shaft.scad +// +// Dimensions trace to design/mechanical/calc/params.py (SI metres -> mm). +// NOTE: the counterweight-FREE study (balance.py) recommends DELETING this shaft; +// it is modelled so the classical GEM option remains fully drawable. +// ============================================================================ + +$fn = 96; + +// ---- parameters (traced to params.py) -------------------------------------- +shaft_d = 31.75; // P.COUNTERWEIGHTS.shaft_dia_m = u.inch(1.25) -> 0.03175 m (303-SS) +shaft_l = 457.2; // P.COUNTERWEIGHTS.shaft_len_m = u.inch(18.0) -> 0.4572 m + +// Weights available = 12.5 kg (2 x 5 kg + 1 x 2.5 kg) = P.COUNTERWEIGHTS.weights_available_kg +// Cast-iron disc geometry is ASSUMED (params gives mass only, not OD/thickness). +disc_bore = 33.0; // slip fit over the shaft — ASSUMED +disc5_od = 127.0; // 5.0 kg disc OD — ASSUMED +disc5_t = 40.0; // 5.0 kg disc thickness — ASSUMED +disc25_od = 102.0; // 2.5 kg disc OD — ASSUMED +disc25_t = 33.0; // 2.5 kg disc thickness — ASSUMED + +// End fittings — ASSUMED. +stud_d = 20.0; stud_l = 26.0; // threaded stud into the DEC block +knob_d = 46.0; knob_h = 16.0; // safety stop knob at the far end + +module _disc(od, t) { + difference() { + cylinder(d = od, h = t); + translate([0, 0, -1]) cylinder(d = disc_bore, h = t + 2); + // knurl-substitute relief so the disc reads as a weight, not a plain ring + translate([0, 0, t/2]) rotate_extrude() translate([od/2 - 3, 0, 0]) square([3, t], center=true); + } +} + +module counterweight_shaft() { + // threaded stud (mount end) at z<0 + translate([0, 0, -stud_l]) cylinder(d = stud_d, h = stud_l); + // main 303-SS shaft + cylinder(d = shaft_d, h = shaft_l); + // safety stop knob at the far (top) end + translate([0, 0, shaft_l]) cylinder(d = knob_d, h = knob_h); + + // Stacked weights near the far end (parked position): 5 + 5 + 2.5 kg. + z0 = shaft_l - 40; + translate([0, 0, z0 - disc5_t]) _disc(disc5_od, disc5_t); + translate([0, 0, z0 - disc5_t - disc5_t]) _disc(disc5_od, disc5_t); + translate([0, 0, z0 - disc5_t - disc5_t - disc25_t]) _disc(disc25_od, disc25_t); +} + +counterweight_shaft(); // standalone; ignored by `use <>` in assembly diff --git a/design/mechanical/cad/dec_housing.scad b/design/mechanical/cad/dec_housing.scad new file mode 100644 index 0000000..b2ed942 --- /dev/null +++ b/design/mechanical/cad/dec_housing.scad @@ -0,0 +1,90 @@ +// ============================================================================ +// NIGHTWATCH GEM — DEC axis housing + Losmandy-D saddle +// Parametric OpenSCAD model. Units: MILLIMETRES. +// +// Render (OpenSCAD not installed here): +// openscad -o dec_housing.stl dec_housing.scad +// openscad -o dec_housing.png --imgsize=1200,900 dec_housing.scad +// +// Dimensions trace to design/mechanical/calc/params.py (SI metres -> mm). +// ============================================================================ + +$fn = 96; + +// ---- parameters (traced to params.py) -------------------------------------- +dec_outer_x = 152.4; // P.DEC_HOUSING.outer_x_m = u.inch(6) -> 0.1524 m +dec_outer_y = 152.4; // P.DEC_HOUSING.outer_y_m = u.inch(6) +dec_depth = 63.5; // P.DEC_HOUSING.depth_m = u.inch(2.5) -> bearing separation +dec_wall = 8.0; // P.DEC_HOUSING.wall_m = u.mm(8) + +brg_od = 55.0; // P.BRG_DEC_6006.od_m = u.mm(55) (6006 output bearing) +brg_bore = 30.0; // P.BRG_DEC_6006.bore_m = u.mm(30) +brg_w = 13.0; // 6006 nominal width — ASSUMED (not in params.py) + +drive_bore = 64.0; // P.DEC_DRIVE.bore_m = u.mm(64) (CSF-25-80 hollow bore) +flange_bc = 83.0; // ~ P.DEC_DRIVE.bore_m ; CSF-25 flange PCD (DERIVED ~1.3*bore) +flange_nbolt = 8; // ASSUMED +flange_bolt_d = 4.5; // M4 clearance — ASSUMED + +// Losmandy-D dovetail (industry standard) — ASSUMED (repo does not dimension it). +losmandy_plate_w = 76.2; // 3.00 in nominal Losmandy-D plate width — ASSUMED std +dovetail_angle = 15.0; // deg, standard dovetail flank — ASSUMED std +saddle_height = 24.0; // jaw block height — ASSUMED +saddle_slot_depth = 14.0; // dovetail engagement depth — ASSUMED +clamp_bolt_d = 8.0; // side clamp knob thread — ASSUMED + +// ---- dovetail channel (trapezoid: wide at base, narrow mouth) --------------- +module _dovetail_channel(chlen, plate_w, ang, depth) { + mouth = plate_w - 2 * depth * tan(ang); // narrower opening at the top + translate([0, chlen / 2, 0]) + rotate([90, 0, 0]) // extrude +Z -> run along -Y + linear_extrude(height = chlen) + polygon(points = [ + [-plate_w / 2, 0], // base (inside), wide + [ plate_w / 2, 0], + [ mouth / 2, depth], // mouth (top), narrow -> jaws grip + [-mouth / 2, depth] + ]); +} + +// ---- DEC bearing/drive housing --------------------------------------------- +module dec_core() { + difference() { + translate([-dec_outer_x/2, -dec_outer_y/2, 0]) + cube([dec_outer_x, dec_outer_y, dec_depth]); + + // Stepped axial bore along +z (the DEC axis): + translate([0, 0, -1]) cylinder(d = brg_od, h = brg_w + 1); + translate([0, 0, brg_w]) cylinder(d = drive_bore, h = dec_depth - brg_w + 1); + translate([0, 0, -1]) cylinder(d = brg_bore - 6, h = dec_depth + 2); + + // CSF-25 flange bolt circle from the rear face. + for (i = [0 : flange_nbolt - 1]) + rotate([0, 0, i * 360 / flange_nbolt]) + translate([flange_bc / 2, 0, dec_depth - 14]) + cylinder(d = flange_bolt_d, h = 18); + } +} + +// ---- Losmandy-D saddle jaw block (sits on the +Y face of the DEC output) ----- +module losmandy_saddle() { + blk_w = losmandy_plate_w + 44; // jaw width straddling the plate — ASSUMED + blk_l = dec_outer_y; // saddle length = DEC housing face + difference() { + translate([-blk_w/2, -blk_l/2, 0]) cube([blk_w, blk_l, saddle_height]); + // dovetail channel, mouth flush with the top surface + translate([0, 0, saddle_height - saddle_slot_depth]) + _dovetail_channel(blk_l + 2, losmandy_plate_w, dovetail_angle, saddle_slot_depth + 2); + // side clamp knob cross-hole + translate([blk_w/2 - 8, 0, saddle_height/2]) + rotate([0, 90, 0]) cylinder(d = clamp_bolt_d, h = 24, center = true); + } +} + +module dec_housing() { + dec_core(); + // Mount the saddle on the DEC output face (+z), lifted clear of the bore boss. + translate([0, 0, dec_depth]) losmandy_saddle(); +} + +dec_housing(); // executed standalone; ignored by `use <>` in assembly diff --git a/design/mechanical/cad/pier_adapter.scad b/design/mechanical/cad/pier_adapter.scad new file mode 100644 index 0000000..95189d1 --- /dev/null +++ b/design/mechanical/cad/pier_adapter.scad @@ -0,0 +1,59 @@ +// ============================================================================ +// NIGHTWATCH GEM — pier adapter plate +// Bridges the concrete pier's steel top plate (12" x 0.375") to the RA housing. +// Parametric OpenSCAD model. Units: MILLIMETRES. +// +// Render (OpenSCAD not installed here): +// openscad -o pier_adapter.stl pier_adapter.scad +// openscad -o pier_adapter.png --imgsize=1200,900 pier_adapter.scad +// +// Dimensions trace to design/mechanical/calc/params.py (SI metres -> mm). +// ============================================================================ + +$fn = 96; + +// ---- parameters ------------------------------------------------------------ +// The adapter plate itself is 10" x 10" x 0.75" — an ADDED part (repo is silent +// on an adapter; tagged ASSUMED). It seats on the SOURCED pier top plate below. +adapter_side = 254.0; // 10 in = u.inch(10) -> ASSUMED (adapter geometry added) +adapter_thk = 19.05; // 0.75 in = u.inch(0.75) -> ASSUMED + +// Below: the pier's steel top plate (SOURCED, params.py P.PIER). +pier_plate_side = 304.8; // P.PIER.top_plate_side_m = u.inch(12) +pier_plate_thk = 9.525; // P.PIER.top_plate_thk_m = u.inch(0.375) (reference) +pier_dia = 304.8; // P.PIER.diameter_m = u.inch(12) (cable-hole guide) + +// Above: the RA housing corner bolt pattern it must accept. +ra_outer = 203.2; // P.RA_HOUSING.outer_x_m = u.inch(8) +ra_corner_inset = 18.0; // must match ra_housing.scad corner_inset — DERIVED + +// Fastener geometry — ASSUMED. +down_bolt_d = 13.5; // M12 anchor pattern into the pier top plate +down_bc = 228.6; // bolt circle to the pier plate (9.0 in square) — ASSUMED +up_bolt_d = 7.0; // M6 tapped-clearance for the RA housing corners +cable_bore = 60.0; // central cable / drawbar pass-through — ASSUMED + +module pier_adapter() { + ra_bc = ra_outer - 2 * ra_corner_inset; // RA housing corner square (mm) + difference() { + // 10" square plate with lightly chamfered footprint (via minkowski-free + // bevel: a plain square keeps the model unambiguous). + translate([-adapter_side/2, -adapter_side/2, 0]) + cube([adapter_side, adapter_side, adapter_thk]); + + // central cable / drawbar bore + translate([0, 0, -1]) cylinder(d = cable_bore, h = adapter_thk + 2); + + // DOWN pattern: 4 bolts on a square to the pier top plate + for (sx = [-1, 1], sy = [-1, 1]) + translate([sx * down_bc/2, sy * down_bc/2, -1]) + cylinder(d = down_bolt_d, h = adapter_thk + 2); + + // UP pattern: 4 tapped holes matching the RA housing corner bolts + for (sx = [-1, 1], sy = [-1, 1]) + translate([sx * ra_bc/2, sy * ra_bc/2, -1]) + cylinder(d = up_bolt_d, h = adapter_thk + 2); + } +} + +pier_adapter(); // standalone; ignored by `use <>` in assembly diff --git a/design/mechanical/cad/preview_assembly.svg b/design/mechanical/cad/preview_assembly.svg new file mode 100644 index 0000000..940c70d --- /dev/null +++ b/design/mechanical/cad/preview_assembly.svg @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +pier 0.914 m (36") +Ø 0.305 m +OTA Intes-Micro MN78: L 1.400 m +CW shaft 0.457 m (18") +polar axis @ lat 38.9° +NIGHTWATCH GEM — meridian elevation (park pose) +OTA Intes-Micro MN78 (f/8) · tube Ø 0.210 m · mass 14 kg + 4 kg train · drawn to scale from params.py + +concrete pier 12" Ø + +adapter 10x10x0.75" + +RA housing (CSF-32, 6008) + +DEC housing + Losmandy-D (CSF-25, 6006) + +OTA tube + +counterweight shaft + \ No newline at end of file diff --git a/design/mechanical/cad/ra_housing.scad b/design/mechanical/cad/ra_housing.scad new file mode 100644 index 0000000..cce12b5 --- /dev/null +++ b/design/mechanical/cad/ra_housing.scad @@ -0,0 +1,66 @@ +// ============================================================================ +// NIGHTWATCH GEM — RA (polar) axis housing +// Parametric OpenSCAD model. Units: MILLIMETRES (OpenSCAD convention). +// +// Render (OpenSCAD is NOT installed in this design sandbox; this is text CAD): +// openscad -o ra_housing.stl ra_housing.scad +// openscad -o ra_housing.png --imgsize=1200,900 ra_housing.scad +// +// Every dimension traces to design/mechanical/calc/params.py so the geometry +// cannot silently drift from the computed design. Edit params.py, then mirror +// the value here (and in the sibling parts). SI metres in params -> mm here. +// ============================================================================ + +$fn = 96; + +// ---- parameters (traced to params.py) -------------------------------------- +ra_outer_x = 203.2; // P.RA_HOUSING.outer_x_m = u.inch(8) -> 0.2032 m +ra_outer_y = 203.2; // P.RA_HOUSING.outer_y_m = u.inch(8) -> 0.2032 m +ra_depth = 76.2; // P.RA_HOUSING.depth_m = u.inch(3) -> bearing separation +ra_wall = 8.0; // P.RA_HOUSING.wall_m = u.mm(8) (Hedrick min; 12 mm bold) + +brg_od = 68.0; // P.BRG_RA_6008.od_m = u.mm(68) (6008 output bearing) +brg_bore = 40.0; // P.BRG_RA_6008.bore_m = u.mm(40) +brg_w = 15.0; // 6008 nominal width — ASSUMED (not dimensioned in params.py) + +drive_bore = 80.0; // P.RA_DRIVE.bore_m = u.mm(80) (CSF-32-100 hollow bore) +// CSF-32 output flange mounting bolt circle scales with the hollow bore. +// DERIVED: ~1.3 x bore keeps the ring clear of the bore wall. Traced ~ P.RA_DRIVE.bore_m. +flange_bc = 104.0; // ~ P.RA_DRIVE.bore_m ; CSF-32 flange PCD (DERIVED) +flange_nbolt = 8; // ASSUMED (CSF-32 flange hole count) +flange_bolt_d = 4.5; // M4 clearance — ASSUMED + +corner_bolt_d = 6.6; // M6 clearance: housing -> RA stage / pier adapter — ASSUMED +corner_inset = 18.0; // ASSUMED + +// ---- part ------------------------------------------------------------------ +module ra_housing() { + difference() { + // Solid 6061-T6 block; production part would be pocket-lightened, kept + // solid here so the parametric proof stays unambiguous. + translate([-ra_outer_x/2, -ra_outer_y/2, 0]) + cube([ra_outer_x, ra_outer_y, ra_depth]); + + // Stepped axial bore along +z (the polar / RA axis): + // front (z=0) : 6008 bearing seat (OD 68) + // rear (z=brg_w+) : opens to the CSF-32 hollow-drive register (80) + translate([0, 0, -1]) cylinder(d = brg_od, h = brg_w + 1); + translate([0, 0, brg_w]) cylinder(d = drive_bore, h = ra_depth - brg_w + 1); + // clear through-hole for drawbar / cable routing + translate([0, 0, -1]) cylinder(d = brg_bore - 6, h = ra_depth + 2); + + // CSF-32 drive flange bolt circle, counter-drilled from the rear face. + for (i = [0 : flange_nbolt - 1]) + rotate([0, 0, i * 360 / flange_nbolt]) + translate([flange_bc / 2, 0, ra_depth - 16]) + cylinder(d = flange_bolt_d, h = 20); + + // Four corner through-holes (housing -> adapter / RA stage). + for (sx = [-1, 1], sy = [-1, 1]) + translate([sx * (ra_outer_x/2 - corner_inset), + sy * (ra_outer_y/2 - corner_inset), -1]) + cylinder(d = corner_bolt_d, h = ra_depth + 2); + } +} + +ra_housing(); // executed when opened directly; ignored by `use <>` in assembly diff --git a/design/mechanical/cad/svg_preview.py b/design/mechanical/cad/svg_preview.py new file mode 100644 index 0000000..75b36a2 --- /dev/null +++ b/design/mechanical/cad/svg_preview.py @@ -0,0 +1,401 @@ +""" +Zero-dependency SVG side-elevation of the NIGHTWATCH German Equatorial Mount. + +This is the *immediate* visual for the CAD package: OpenSCAD is not installed in +the design sandbox, so rather than ship un-renderable ``.step`` stubs (the repo's +current state — every CAD file is "Pending"), this module draws a correctly- +proportioned meridian-plane elevation straight from the computed design. + +Every dimension is read live from ``design.mechanical.calc.params`` — nothing is +hardcoded that already lives in params.py — so the drawing cannot drift from the +proofs. The pose is the classic GEM park: polar (RA) axis inclined at the site +latitude, OTA pointing at the celestial pole, counterweight shaft hanging on the +"down" side. + +Pure standard-library string templating; run it to (re)write ``preview_assembly.svg``: + + python3 design/mechanical/cad/svg_preview.py +""" + +from __future__ import annotations + +import math +import sys +from pathlib import Path + +# Allow ``python3 design/mechanical/cad/svg_preview.py`` from anywhere: put the +# repo root (…/NIGHTWATCH) on sys.path so the calc package imports cleanly. +_REPO_ROOT = Path(__file__).resolve().parents[3] +if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) + +from design.mechanical.calc import params as P # noqa: E402 +from design.mechanical.calc import units as u # noqa: E402 + +DEFAULT_OUT = Path(__file__).resolve().parent / "preview_assembly.svg" + +# -------------------------------------------------------------------------- +# 2-D vector helpers (metres, maths frame: +x = north/right, +y = up). +# -------------------------------------------------------------------------- +Vec = tuple[float, float] + + +def _add(a: Vec, b: Vec) -> Vec: + return (a[0] + b[0], a[1] + b[1]) + + +def _mul(a: Vec, s: float) -> Vec: + return (a[0] * s, a[1] * s) + + +def polar_axis_unit(lat_deg: float) -> Vec: + """Unit vector of the polar (RA) axis: altitude = latitude, toward +x.""" + r = math.radians(lat_deg) + return (math.cos(r), math.sin(r)) + + +def _perp_up(lat_deg: float) -> Vec: + """Unit vector _|_ to the polar axis, on the OTA (up) side.""" + r = math.radians(lat_deg) + return (-math.sin(r), math.cos(r)) + + +def _perp_down(lat_deg: float) -> Vec: + """Unit vector _|_ to the polar axis, on the counterweight (down) side.""" + r = math.radians(lat_deg) + return (math.sin(r), -math.cos(r)) + + +def _oriented_rect(center: Vec, along: Vec, cross: Vec, length: float, width: float) -> list[Vec]: + """Four corners of a rectangle centred at ``center`` with the given axes.""" + a = _mul(along, length / 2.0) + c = _mul(cross, width / 2.0) + return [ + _add(_add(center, a), c), + _add(_add(center, a), _mul(c, -1)), + _add(_add(center, _mul(a, -1)), _mul(c, -1)), + _add(_add(center, _mul(a, -1)), c), + ] + + +# -------------------------------------------------------------------------- +# Geometry model — all lengths in metres, straight from params.py. +# -------------------------------------------------------------------------- +def build_geometry(ota: P.OTA | None = None) -> dict: + """Assemble the meridian-plane geometry of the GEM from params.py. + + Returns a dict of named parts (each a list of corner Vecs) plus the scalar + dimensions used for annotation. DERIVED placement constants are flagged. + """ + if ota is None: + ota = P.MN78 # params default across the proofs + + lat = P.SITE.latitude_deg + u_ax = polar_axis_unit(lat) + up = _perp_up(lat) + down = _perp_down(lat) + + # --- pier + plates (stacked on +y) --- + pier_h = P.PIER.height_above_m + pier_d = P.PIER.diameter_m + tp_thk = P.PIER.top_plate_thk_m + tp_side = P.PIER.top_plate_side_m + adapter_thk = u.inch(0.75) # pier_adapter.scad, ASSUMED added part + adapter_side = u.inch(10.0) # pier_adapter.scad, ASSUMED added part + + pier = [(-pier_d / 2, 0.0), (pier_d / 2, 0.0), (pier_d / 2, pier_h), (-pier_d / 2, pier_h)] + y0 = pier_h + top_plate = [(-tp_side / 2, y0), (tp_side / 2, y0), + (tp_side / 2, y0 + tp_thk), (-tp_side / 2, y0 + tp_thk)] + y0 += tp_thk + adapter = [(-adapter_side / 2, y0), (adapter_side / 2, y0), + (adapter_side / 2, y0 + adapter_thk), (-adapter_side / 2, y0 + adapter_thk)] + adapter_top = y0 + adapter_thk + + # --- RA housing on the polar axis --- + ra_depth = P.RA_HOUSING.depth_m + ra_face = P.RA_HOUSING.outer_x_m + dec_face = P.DEC_HOUSING.outer_x_m + dec_depth = P.DEC_HOUSING.depth_m + + # RA housing centre: lifted off the adapter so the tilted block clears it. DERIVED. + p_ra = (0.0, adapter_top + ra_face / 2.0 * math.sin(math.radians(lat)) + 0.02) + ra_housing = _oriented_rect(p_ra, u_ax, up, ra_depth, ra_face) + + # DEC head at the far end of the RA axis. DERIVED seat distance. + L_ra = ra_depth + dec_face / 2.0 + 0.04 + c_dec = _add(p_ra, _mul(u_ax, L_ra)) + dec_housing = _oriented_rect(c_dec, u_ax, up, dec_depth, dec_face) + + # --- OTA tube, parallel to the polar axis, on the saddle (up) side --- + saddle = 0.05 # stiffness.SADDLE_LEVER_M, ASSUMED + ota_offset = dec_face / 2.0 + saddle + ota.tube_od_m / 2.0 + tube_center = _add(c_dec, _mul(up, ota_offset)) + # saddle ~1/3 up the tube: shift the tube so more of it rises toward the pole + tube_center = _add(tube_center, _mul(u_ax, ota.tube_length_m / 6.0)) + ota_tube = _oriented_rect(tube_center, u_ax, up, ota.tube_length_m, ota.tube_od_m) + + # --- counterweight shaft on the down side --- + cw_len = P.COUNTERWEIGHTS.shaft_len_m + cw_dia = P.COUNTERWEIGHTS.shaft_dia_m + cw_start = _add(c_dec, _mul(down, dec_face / 2.0)) + cw_center = _add(cw_start, _mul(down, cw_len / 2.0)) + cw_shaft = _oriented_rect(cw_center, down, up, cw_len, cw_dia) + + # counterweight discs near the far end of the shaft (12.5 kg available) + disc_od = 0.127 + discs = [] + for k, frac in enumerate((0.72, 0.82, 0.92)): + dc = _add(cw_start, _mul(down, cw_len * frac)) + discs.append(_oriented_rect(dc, down, up, 0.04, disc_od)) + + return { + "ota": ota, + "lat": lat, + "u_axis": u_ax, + "parts": { + "pier": pier, + "top_plate": top_plate, + "adapter": adapter, + "ra_housing": ra_housing, + "dec_housing": dec_housing, + "ota_tube": ota_tube, + "cw_shaft": cw_shaft, + }, + "discs": discs, + "dims": { + "pier_h": pier_h, + "pier_d": pier_d, + "tube_len": ota.tube_length_m, + "tube_od": ota.tube_od_m, + "cw_len": cw_len, + "cw_dia": cw_dia, + "adapter_side": adapter_side, + "adapter_thk": adapter_thk, + }, + "anchors": { + "p_ra": p_ra, + "c_dec": c_dec, + "tube_center": tube_center, + "cw_start": cw_start, + "adapter_top": adapter_top, + }, + } + + +# -------------------------------------------------------------------------- +# SVG rendering. +# -------------------------------------------------------------------------- +_STYLE = { + "pier": ("#8a8577", "#4a463d"), + "top_plate": ("#4c4c52", "#2a2a2e"), + "adapter": ("#c9a83d", "#7a6417"), + "ra_housing": ("#b9c2c8", "#5d666c"), + "dec_housing": ("#a9b4bb", "#525b61"), + "ota_tube": ("#20232a", "#000000"), + "cw_shaft": ("#3a3a40", "#101014"), +} + + +def _fmt_m(x: float) -> str: + return f"{x:.3f} m" + + +def _fmt_mm(x: float) -> str: + return f"{x * 1000:.0f} mm" + + +def render_svg(ota: P.OTA | None = None, scale_px_per_m: float = 300.0) -> str: + """Render the GEM meridian elevation to an SVG string (pure stdlib).""" + geo = build_geometry(ota) + ota = geo["ota"] + + # Collect all points to compute bounds (maths frame, +y up). + all_pts: list[Vec] = [] + for poly in geo["parts"].values(): + all_pts.extend(poly) + for d in geo["discs"]: + all_pts.extend(d) + + xs = [p[0] for p in all_pts] + ys = [p[1] for p in all_pts] + min_x, max_x = min(xs), max(xs) + min_y, max_y = min(ys), max(0.0, max(ys)) # include grade (y=0) + + pad_m = 0.55 + label_pad_m = 1.15 # extra room on the right for the dimension column + min_x -= pad_m + max_x += pad_m + label_pad_m + min_y -= pad_m + max_y += pad_m + + S = scale_px_per_m + width_px = (max_x - min_x) * S + height_px = (max_y - min_y) * S + + def tx(x: float) -> float: + return (x - min_x) * S + + def ty(y: float) -> float: + return (max_y - y) * S # flip: SVG +y is down + + def poly_svg(poly: list[Vec]) -> str: + return " ".join(f"{tx(px):.1f},{ty(py):.1f}" for px, py in poly) + + parts: list[str] = [] + + # --- header + defs --- + parts.append( + f'' + ) + parts.append( + '' + ) + + # --- ground line + hatch --- + gy = ty(0.0) + parts.append( + f'' + ) + hx = 0.0 + while hx < width_px: + parts.append( + f'' + ) + hx += 18 + + # --- polar-axis centreline (through RA housing to DEC head) --- + p_ra = geo["anchors"]["p_ra"] + u_ax = geo["u_axis"] + a0 = _add(p_ra, _mul(u_ax, -0.12)) + a1 = _add(geo["anchors"]["c_dec"], _mul(u_ax, 0.20)) + parts.append( + f'' + ) + + # --- parts (draw pier first so the head overlaps it) --- + order = ["pier", "top_plate", "adapter", "cw_shaft", "ra_housing", "dec_housing", "ota_tube"] + for name in order: + fill, stroke = _STYLE[name] + parts.append( + f'' + ) + + # counterweight discs + for d in geo["discs"]: + parts.append( + f'' + ) + + # --- dimension annotations (real numbers) --- + dims = geo["dims"] + + def dim_line(x1, y1, x2, y2, text, ty_off=-6, color="#20232a"): + out = [ + f'' + ] + mx = (tx(x1) + tx(x2)) / 2.0 + my = (ty(y1) + ty(y2)) / 2.0 + ty_off + out.append( + f'{text}' + ) + return "".join(out) + + # arrow marker + parts.append( + '' + ) + + # pier height dimension (left of pier) + xdim = -P.PIER.diameter_m / 2 - 0.30 + parts.append(dim_line(xdim, 0.0, xdim, dims["pier_h"], + f'pier {_fmt_m(dims["pier_h"])} ({dims["pier_h"] / u.IN_TO_M:.0f}")', + ty_off=0, color="#4a463d")) + parts.append( + f'' + f'Ø {_fmt_m(dims["pier_d"])}' + ) + + # OTA tube length dimension (along the tube axis) + ota_poly = geo["parts"]["ota_tube"] + up = _perp_up(geo["lat"]) + tl_a = _add(ota_poly[3], _mul(up, 0.12)) # near end top-left corner + tl_b = _add(ota_poly[2], _mul(up, 0.12)) # far end + parts.append(dim_line(tl_a[0], tl_a[1], tl_b[0], tl_b[1], + f'OTA {ota.name.split("(")[0].strip()}: L {_fmt_m(dims["tube_len"])}', + ty_off=-8, color="#20232a")) + + # counterweight shaft length + cw_poly = geo["parts"]["cw_shaft"] + parts.append(dim_line(cw_poly[0][0], cw_poly[0][1], cw_poly[3][0], cw_poly[3][1], + f'CW shaft {_fmt_m(dims["cw_len"])} ({dims["cw_len"] / u.IN_TO_M:.0f}")', + ty_off=14, color="#101014")) + + # latitude callout at the RA housing + parts.append( + f'polar axis @ lat {geo["lat"]:.1f}°' + ) + + # --- title + legend block (top-left) --- + tube_od_txt = _fmt_m(dims["tube_od"]) + parts.append( + '' + 'NIGHTWATCH GEM — meridian elevation (park pose)' + ) + parts.append( + f'' + f'OTA {ota.name} · tube Ø {tube_od_txt} · ' + f'mass {ota.mass_kg:.0f} kg + {P.IMAGING_TRAIN.mass_kg:.0f} kg train · ' + f'drawn to scale from params.py' + ) + + legend = [ + ("pier", "concrete pier 12\" Ø"), + ("adapter", "adapter 10x10x0.75\""), + ("ra_housing", "RA housing (CSF-32, 6008)"), + ("dec_housing", "DEC housing + Losmandy-D (CSF-25, 6006)"), + ("ota_tube", "OTA tube"), + ("cw_shaft", "counterweight shaft"), + ] + ly = 64 + for key, txt in legend: + fill, stroke = _STYLE[key] + parts.append( + f'' + ) + parts.append(f'{txt}') + ly += 18 + + parts.append("") + return "\n".join(parts) + + +def main(out_path: str | Path = DEFAULT_OUT, ota: P.OTA | None = None) -> Path: + out_path = Path(out_path) + svg = render_svg(ota) + out_path.write_text(svg, encoding="utf-8") + return out_path + + +if __name__ == "__main__": + p = main() + geo = build_geometry() + print(f"wrote {p} ({p.stat().st_size} bytes)") + print(f" OTA : {geo['ota'].name}") + print(f" pier height: {geo['dims']['pier_h']:.3f} m") + print(f" tube length: {geo['dims']['tube_len']:.3f} m") + print(f" CW shaft : {geo['dims']['cw_len']:.3f} m") diff --git a/design/mechanical/calc/__init__.py b/design/mechanical/calc/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/design/mechanical/calc/balance.py b/design/mechanical/calc/balance.py new file mode 100644 index 0000000..3958ab3 --- /dev/null +++ b/design/mechanical/calc/balance.py @@ -0,0 +1,185 @@ +""" +Mass-balance proof — can the mount be balanced, and can the counterweights be +deleted entirely? + +Two questions, one module: + +Counterweighted (classical GEM) + Solve the RA moment balance ``m_cw * r_cw = m_payload * r_payload`` for the + shaft position of the available 12.5 kg of weights, and check it lands inside + the 18" (0.457 m) counterweight shaft. DEC fore-aft balance is qualitative: + it is set by sliding the OTA in its dovetail saddle, needs no counterweight, + and leaves only a small residual lever (``torque.DEC_RESIDUAL_LEVER_M``). + +Counterweight-FREE (the recommended configuration) + A strain-wave drive back-drives so poorly that the torque proof + (``torque.evaluate(..., counterweight_free=True)``) already shows the RA + harmonic holds the *unbalanced* payload with margin. This module quantifies + what deleting the counterweight buys: the removed mass (weights + the 303-SS + shaft's own ``rho*A*L``) and the reduced RA polar-axis inertia. + +Headline decision: the mount balances comfortably within the existing shaft, AND +the counterweight-free option is viable (per the torque proof) — deleting ~15 kg +of steel and ~29% of the RA slewing inertia. +""" + +from __future__ import annotations + +import math + +from . import params as P +from . import torque +from . import units as u +from .budget import BudgetResult, Verdict + + +def _payload_mass(ota: P.OTA) -> float: + """OTA + lumped rear imaging train (single source: params).""" + return ota.mass_kg + P.IMAGING_TRAIN.mass_kg + + +def shaft_mass_kg(cw: P.CounterweightSystem = P.COUNTERWEIGHTS) -> float: + """Mass of the solid 303-SS counterweight shaft = rho * A * L. DERIVED.""" + area = math.pi / 4.0 * cw.shaft_dia_m ** 2 + return cw.material.rho * area * cw.shaft_len_m + + +def deleted_mass_kg(cw: P.CounterweightSystem = P.COUNTERWEIGHTS) -> float: + """Mass removed by going counterweight-FREE: the weights + the shaft itself.""" + return cw.weights_available_kg + shaft_mass_kg(cw) + + +def required_counterweight_position_m( + ota: P.OTA = P.MN78, + m_cw: float | None = None, + r_payload: float | None = None, +) -> float: + """ + Solve ``m_cw * r_cw = m_payload * r_payload`` for the shaft position r_cw. + + Treats the weights as a point mass (ignores the shaft's own distributed + restoring moment) — CONSERVATIVE: crediting the shaft moment would only pull + r_cw inward, so the point-mass answer is the furthest-out (worst) case. + """ + if m_cw is None: + m_cw = P.COUNTERWEIGHTS.weights_available_kg + if r_payload is None: + r_payload = torque.RA_CG_OFFSET_M + return _payload_mass(ota) * r_payload / m_cw + + +def _ra_payload_inertia_kgm2(ota: P.OTA) -> float: + """RA polar-axis inertia of the payload alone (same model as torque.py).""" + return _payload_mass(ota) * torque.RA_CG_OFFSET_M ** 2 + \ + (1.0 / 12.0) * ota.mass_kg * ota.tube_length_m ** 2 + + +def deleted_ra_inertia_kgm2( + ota: P.OTA = P.MN78, cw: P.CounterweightSystem = P.COUNTERWEIGHTS +) -> float: + """ + RA polar-axis inertia removed with the counterweight system: the weights as + a point mass at r_cw, plus the shaft as a uniform rod about the axis end + (I = 1/3 m L^2). DERIVED. + """ + r_cw = required_counterweight_position_m(ota, cw.weights_available_kg) + i_weights = cw.weights_available_kg * r_cw ** 2 + i_shaft = (1.0 / 3.0) * shaft_mass_kg(cw) * cw.shaft_len_m ** 2 + return i_weights + i_shaft + + +def evaluate(ota_key: str = "MN78") -> BudgetResult: + ota = P.OTA_CASES[ota_key] + cw = P.COUNTERWEIGHTS + + # --- Counterweighted RA moment balance --- + m_pay = _payload_mass(ota) + r_pay = torque.RA_CG_OFFSET_M + payload_moment = m_pay * r_pay # kg*m + r_cw = required_counterweight_position_m(ota) # m + shaft_len = cw.shaft_len_m + fits = r_cw <= shaft_len + fit_factor = shaft_len / r_cw # >1 => inside the shaft + + # --- DEC fore-aft (qualitative) --- + # Rear imaging stack hangs behind the DEC axis; balanced by sliding the OTA + # in the saddle. Residual lever after adjustment = torque.DEC_RESIDUAL_LEVER_M. + dec_rear_moment_Nm = u.weight_N(P.IMAGING_TRAIN.mass_kg) * P.IMAGING_TRAIN.offset_behind_tube_m + dec_residual_moment_Nm = u.weight_N(m_pay) * torque.DEC_RESIDUAL_LEVER_M + + # --- Counterweight-FREE deletion --- + del_mass = deleted_mass_kg(cw) + del_I = deleted_ra_inertia_kgm2(ota, cw) + i_pay = _ra_payload_inertia_kgm2(ota) + inertia_reduction_frac = del_I / (i_pay + del_I) + + # Counterweight-free viability is decided by the torque proof, not re-derived. + tq = torque.evaluate(ota_key, counterweight_free=True) + cwfree_ok = tq.verdict in (Verdict.PASS, Verdict.MARGINAL) + + if fits and cwfree_ok: + verdict = Verdict.PASS + elif fits: + verdict = Verdict.MARGINAL # balances, but torque proof did not clear cw-free + else: + verdict = Verdict.FAIL # cannot balance within the shaft + + res = BudgetResult( + key="balance", + title="Mass-balance proof (counterweighted & counterweight-free)", + verdict=verdict, + headline=( + f"{ota.name}: {cw.weights_available_kg:.1f} kg balances the {m_pay:.0f} kg payload at " + f"r_cw={r_cw*1000:.0f} mm on the {shaft_len*1000:.0f} mm shaft (fit factor {fit_factor:.2f}). " + f"Counterweight-FREE deletes {del_mass:.1f} kg and {inertia_reduction_frac*100:.0f}% of RA " + f"inertia — viable per the torque proof (RA SF {tq.safety_factor:.1f})." + ), + target="RA balance achievable within shaft length AND counterweight-free viable (torque proof)", + safety_factor=fit_factor, + ) + + # --- Counterweighted balance --- + res.add("Payload mass (OTA + train)", m_pay, "kg") + res.add("Payload CG offset from polar axis (r_payload)", r_pay, "m", "torque.RA_CG_OFFSET_M") + res.add("Payload moment about RA axis", payload_moment, "kg*m", "m_payload * r_payload") + res.add("Counterweights available", cw.weights_available_kg, "kg", "2x5 + 1x2.5 kg") + res.add("Required shaft position r_cw", r_cw, "m", "m_cw * r_cw = m_pay * r_pay") + res.add("Shaft length available", shaft_len, "m", '18" 303-SS shaft') + res.add("Fit factor (shaft_len / r_cw)", fit_factor, "x", ">1 => fits") + + # --- DEC fore-aft (qualitative) --- + res.add("DEC rear-stack imbalance moment", dec_rear_moment_Nm, "Nm", + "train weight x 150 mm; nulled by dovetail slide") + res.add("DEC residual moment after adjust", dec_residual_moment_Nm, "Nm", + "residual lever 20 mm (torque.DEC_RESIDUAL_LEVER_M)") + + # --- Counterweight-FREE deletion --- + res.add("Counterweight shaft mass (rho*A*L)", shaft_mass_kg(cw), "kg", "303-SS solid rod") + res.add("Total deleted mass (cw-free)", del_mass, "kg", "weights + shaft") + res.add("RA payload inertia (polar axis)", i_pay, "kg*m^2") + res.add("Deleted RA inertia (weights+shaft)", del_I, "kg*m^2") + res.add("RA inertia reduction (cw-free)", inertia_reduction_frac * 100.0, "%") + res.add("Counterweight-free viable (torque)", 1.0 if cwfree_ok else 0.0, "bool", + f"torque verdict {tq.verdict.value}") + + res.assumptions = [ + f"r_payload = RA CG offset {r_pay*1000:.0f} mm (imported from torque.RA_CG_OFFSET_M, DERIVED).", + "Counterweights modelled as a point mass; the shaft's own distributed moment is NOT " + "credited toward balance -> conservative (pushes r_cw outward, not inward).", + f"Counterweight shaft mass = rho*A*L of 303-SS " + f"(rho={cw.material.rho:.0f} kg/m^3, d={cw.shaft_dia_m*1000:.1f} mm, L={cw.shaft_len_m*1000:.0f} mm) " + f"= {shaft_mass_kg(cw):.2f} kg, DERIVED.", + "DEC fore-aft balance is set by sliding the OTA in its dovetail saddle (no DEC counterweight); " + f"residual lever {torque.DEC_RESIDUAL_LEVER_M*1000:.0f} mm from torque.DEC_RESIDUAL_LEVER_M, ASSUMED.", + "Deleted RA inertia = weights as point mass at r_cw + shaft as a rod about the axis end " + "(I = 1/3 m L^2), DERIVED.", + f"Counterweight-free viability is inherited from the torque proof " + f"(torque.evaluate('{ota_key}', counterweight_free=True) => {tq.verdict.value}), not re-derived here.", + ] + return res + + +if __name__ == "__main__": + r = evaluate() + print(r.verdict.symbol, r.headline) + print(r.markdown_table()) diff --git a/design/mechanical/calc/bearings.py b/design/mechanical/calc/bearings.py new file mode 100644 index 0000000..d08c23e --- /dev/null +++ b/design/mechanical/calc/bearings.py @@ -0,0 +1,173 @@ +""" +Bearing proof — L10 rolling-fatigue life and static safety of the axis output +bearings, and the finding that matters more than either number. + +Load model (per axis) + The output bearing pair carries the cantilevered payload. Each pair sees a + direct transverse (radial) load ~ payload weight, plus a couple that reacts + the overturning moment M = W * lever across the bearing span s: F = M / s. + The governing bearing load is taken as ``P = W + M/s`` (conservative: the + direct component is not credited with load-sharing between the two rows). + +L10 fatigue + ``L10 = (C / P)**3 * 1e6`` revolutions, C = dynamic rating. The RA axis turns + only ~1 rev per sidereal day; converting L10 to YEARS shows the fatigue life + is astronomical (~1e7-1e8 yr). Rolling fatigue is NOT the constraint on this + mount and never will be. + +Static safety + ``S0 = C0 / P`` (C0 = static rating). Comfortable (~14-18x) at these loads. + +THE FINDING (headline + assumptions): the Build Package calls the 6008/6006 units +"angular contact", but 60xx are DEEP-GROOVE ball bearings. Deep-groove bearings +have poor MOMENT (tilting) stiffness — and moment stiffness, not load rating, is +what sets a telescope mount's pointing deflection. The recommendation is to use +matched ANGULAR-CONTACT pairs (7008 on RA, 7006 on DEC) in a back-to-back (O) +arrangement, which ties directly to the stiffness proof. The load-capacity verdict +here is PASS with room to spare; the real bearing decision is a stiffness decision. +""" + +from __future__ import annotations + +from . import params as P +from . import torque +from . import units as u +from .budget import BudgetResult, verdict_from_sf + +# Sidereal tracking rate: the RA axis completes ~1 revolution per sidereal day. +# Used for the L10 revolutions -> years conversion. DEC turns far less, so 1/day +# is a conservative (over-cycled) common figure for both. SOURCED (sidereal rate). +TRACK_REV_PER_DAY = 1.0 +DAYS_PER_YEAR = 365.25 + +# Transverse offset from the DEC axis to the OTA CG (tube centreline sits above +# the DEC bearing face). Modelled as tube radius + a dovetail/ring saddle stack. +# The saddle stack is ASSUMED; the tube radius is SOURCED (params tube_od_m). +DEC_SADDLE_STACK_M = 0.05 # ASSUMED: dovetail saddle + ring height, DEC face -> tube surface + +# Static-safety pass threshold. ISO 76 wants S0 ~1-2 for smooth rotation, higher +# for shock; 2.0 is a conservative pass gate here. ASSUMED design gate. +STATIC_SAFETY_PASS = 2.0 + + +def _payload_mass(ota: P.OTA) -> float: + return ota.mass_kg + P.IMAGING_TRAIN.mass_kg + + +def bearing_radial_load(weight_N: float, lever_m: float, span_m: float) -> float: + """ + Governing output-bearing radial load: direct transverse weight plus the + moment-couple reaction M/span. Conservative (no load-sharing credit on W). + """ + moment = weight_N * lever_m + couple = moment / span_m + return weight_N + couple + + +def ra_output_load(ota: P.OTA) -> float: + """RA output-bearing load: payload weight cantilevered at the RA CG offset.""" + W = u.weight_N(_payload_mass(ota)) + return bearing_radial_load(W, torque.RA_CG_OFFSET_M, P.RA_HOUSING.depth_m) + + +def dec_output_load(ota: P.OTA) -> float: + """DEC output-bearing load: OTA weight offset from the DEC axis (tube CG above axis).""" + W = u.weight_N(_payload_mass(ota)) + lever = ota.tube_od_m / 2.0 + DEC_SADDLE_STACK_M + return bearing_radial_load(W, lever, P.DEC_HOUSING.depth_m) + + +def l10_revolutions(bearing: P.Bearing, load_N: float) -> float: + """Basic rating life L10 = (C/P)^3 * 1e6 revolutions (ball bearings).""" + return (bearing.C_dynamic_N / load_N) ** 3 * 1.0e6 + + +def l10_years(bearing: P.Bearing, load_N: float, rev_per_day: float = TRACK_REV_PER_DAY) -> float: + """L10 expressed in YEARS at a given revolutions-per-day duty.""" + return l10_revolutions(bearing, load_N) / (rev_per_day * DAYS_PER_YEAR) + + +def static_safety(bearing: P.Bearing, load_N: float) -> float: + """Static safety factor S0 = C0 / P (ISO 76).""" + return bearing.C0_static_N / load_N + + +def evaluate(ota_key: str = "MN78") -> BudgetResult: + ota = P.OTA_CASES[ota_key] + + ra_load = ra_output_load(ota) + dec_load = dec_output_load(ota) + + ra_L10_yr = l10_years(P.BRG_RA_6008, ra_load) + dec_L10_yr = l10_years(P.BRG_DEC_6006, dec_load) + + ra_S0 = static_safety(P.BRG_RA_6008, ra_load) + dec_S0 = static_safety(P.BRG_DEC_6006, dec_load) + + ra_moment = u.weight_N(_payload_mass(ota)) * torque.RA_CG_OFFSET_M + dec_moment = u.weight_N(_payload_mass(ota)) * (ota.tube_od_m / 2.0 + DEC_SADDLE_STACK_M) + + governing_S0 = min(ra_S0, dec_S0) + # Load-capacity verdict (fatigue + static). Fatigue is a non-issue by ~1e7 + # years, so static safety governs. The moment-stiffness caveat below is the + # real design driver and is flagged loudly, but it belongs to stiffness.py. + verdict = verdict_from_sf(governing_S0, pass_at=STATIC_SAFETY_PASS, marginal_at=1.0) + + res = BudgetResult( + key="bearings", + title="Axis bearing L10 fatigue + static safety", + verdict=verdict, + headline=( + f"Load capacity is NOT the constraint: L10 ~ {ra_L10_yr:.0e} yr (RA) / {dec_L10_yr:.0e} yr " + f"(DEC) at ~1 rev/sidereal-day, static S0 {ra_S0:.0f}x / {dec_S0:.0f}x. FINDING: the repo " + f"calls the 6008/6006 'angular contact' but 60xx are DEEP-GROOVE with poor moment stiffness " + f"— use matched angular-contact 7008/7006 pairs (back-to-back) for moment stiffness (see stiffness proof)." + ), + target=f"L10 >> service life AND static S0 >= {STATIC_SAFETY_PASS:.0f} (fatigue not the constraint)", + safety_factor=governing_S0, + ) + + res.add("Payload mass (OTA + train)", _payload_mass(ota), "kg") + # RA + res.add("RA overturning moment (W x CG offset)", ra_moment, "Nm", "lever = RA_CG_OFFSET_M") + res.add("RA bearing span (housing depth)", P.RA_HOUSING.depth_m, "m", "sets couple arm") + res.add("RA output bearing load P", ra_load, "N", "W + M/span (6008)") + res.add("RA dynamic rating C", P.BRG_RA_6008.C_dynamic_N, "N") + res.add("RA L10 life", l10_revolutions(P.BRG_RA_6008, ra_load), "rev", "(C/P)^3 x 1e6") + res.add("RA L10 life", ra_L10_yr, "yr", "@ ~1 rev/sidereal day") + res.add("RA static safety S0 = C0/P", ra_S0, "x", f"C0={P.BRG_RA_6008.C0_static_N:.0f} N") + # DEC + res.add("DEC overturning moment (W x lever)", dec_moment, "Nm", "lever = tube r + saddle stack") + res.add("DEC bearing span (housing depth)", P.DEC_HOUSING.depth_m, "m") + res.add("DEC output bearing load P", dec_load, "N", "W + M/span (6006)") + res.add("DEC dynamic rating C", P.BRG_DEC_6006.C_dynamic_N, "N") + res.add("DEC L10 life", dec_L10_yr, "yr", "@ ~1 rev/sidereal day") + res.add("DEC static safety S0 = C0/P", dec_S0, "x", f"C0={P.BRG_DEC_6006.C0_static_N:.0f} N") + + res.assumptions = [ + "Governing bearing load P = W + M/span (direct transverse weight + moment couple); " + "no load-sharing credit on the direct term -> conservative.", + f"RA lever = RA_CG_OFFSET_M {torque.RA_CG_OFFSET_M*1000:.0f} mm (from torque, DERIVED); " + f"DEC lever = tube radius {ota.tube_od_m/2*1000:.0f} mm + saddle stack {DEC_SADDLE_STACK_M*1000:.0f} mm " + f"(saddle stack ASSUMED).", + "Bearing span = housing depth (P.RA_HOUSING.depth_m / P.DEC_HOUSING.depth_m); axis tilt at " + "latitude would reduce the transverse component, so vertical W is conservative.", + f"L10 = (C/P)^3 x 1e6 rev; converted to years at {TRACK_REV_PER_DAY:.0f} rev/sidereal day " + f"(~1/day). Both axes exceed 1e7 years -> rolling fatigue is NOT a lifetime constraint.", + "FINDING: params labels BRG_RA_6008 / BRG_DEC_6006 'angular contact', but 60xx are DEEP-GROOVE " + "ball bearings (params.py already flags the deep-groove C ratings). Deep-groove bearings have " + "poor moment/tilting stiffness.", + "RECOMMENDATION: replace with matched ANGULAR-CONTACT pairs — 7008 (RA) / 7006 (DEC) — in a " + "back-to-back (O) arrangement with preload, which provides the moment stiffness that sets " + "pointing deflection. This is a STIFFNESS decision, not a load-rating one (see stiffness proof).", + "The RA output bearing additionally carries DEC-head and (if fitted) counterweight dead weight " + "not modelled here; even at 2-3x the modelled load, S0 stays > 5 and L10 > 1e6 yr, so the " + "conclusion is robust.", + ] + return res + + +if __name__ == "__main__": + r = evaluate() + print(r.verdict.symbol, r.headline) + print(r.markdown_table()) diff --git a/design/mechanical/calc/budget.py b/design/mechanical/calc/budget.py new file mode 100644 index 0000000..9cd897b --- /dev/null +++ b/design/mechanical/calc/budget.py @@ -0,0 +1,87 @@ +""" +Common result type for every budget module. + +Each proof (`torque`, `stiffness`, ...) exposes ``evaluate() -> BudgetResult``. +The report is then a pure function of these results, and a test can regenerate +the report tables and diff them against the calculator so no hand-typed number +can drift from the computation. +""" + +from __future__ import annotations + +import enum +from dataclasses import dataclass, field + + +class Verdict(enum.Enum): + PASS = "PASS" + MARGINAL = "MARGINAL" + FAIL = "FAIL" + + @property + def symbol(self) -> str: + return {"PASS": "✅", "MARGINAL": "⚠️", "FAIL": "❌"}[self.value] + + +@dataclass(frozen=True) +class Line: + """One computed quantity in a budget table.""" + label: str + value: float + unit: str + note: str = "" + + def fmt(self, sig: int = 4) -> str: + v = self.value + if v == 0: + s = "0" + elif abs(v) >= 1e5 or (abs(v) < 1e-3 and v != 0): + s = f"{v:.{sig - 1}e}" + else: + s = f"{v:.{sig}g}" + return s + + +@dataclass +class BudgetResult: + key: str # stable id, e.g. "torque" + title: str + verdict: Verdict + headline: str # one-line human summary + lines: list[Line] = field(default_factory=list) + assumptions: list[str] = field(default_factory=list) + safety_factor: float | None = None + target: str = "" # the requirement being checked + + def add(self, label: str, value: float, unit: str, note: str = "") -> None: + self.lines.append(Line(label, value, unit, note)) + + def markdown_table(self) -> str: + rows = ["| Quantity | Value | Unit | Note |", "|---|---:|---|---|"] + for ln in self.lines: + rows.append(f"| {ln.label} | {ln.fmt()} | {ln.unit} | {ln.note} |") + return "\n".join(rows) + + def as_dict(self) -> dict: + return { + "key": self.key, + "title": self.title, + "verdict": self.verdict.value, + "headline": self.headline, + "safety_factor": None if self.safety_factor is None else round(self.safety_factor, 4), + "target": self.target, + "lines": [ + {"label": ln.label, "value": ln.value, "unit": ln.unit, "note": ln.note} + for ln in self.lines + ], + "assumptions": list(self.assumptions), + } + + +def verdict_from_sf(sf: float, pass_at: float = 2.0, marginal_at: float = 1.0) -> Verdict: + """Standard mapping from a safety factor to a verdict.""" + if sf >= pass_at: + return Verdict.PASS + if sf >= marginal_at: + return Verdict.MARGINAL + return Verdict.FAIL diff --git a/design/mechanical/calc/dynamics.py b/design/mechanical/calc/dynamics.py new file mode 100644 index 0000000..3d92e60 --- /dev/null +++ b/design/mechanical/calc/dynamics.py @@ -0,0 +1,191 @@ +""" +First-mode dynamics proof — is the mount's lowest natural frequency safely above +P.NATURAL_FREQ_TARGET_HZ (10 Hz)? + +Why 10 Hz matters: wind-gust energy on a compact tube is concentrated below ~2 Hz +and the OnStepX servo/guide loop operates at ~1-5 Hz. A structural resonance near +those bands would be excited by both the wind and the controller, wrecking +tracking. The first mode must sit well clear -- the repo asserts > 10 Hz. + +Two complementary models, both built from the same beam/bearing stiffnesses used +by ``stiffness.py``: + + 1. Translational bounce (the prescribed model): the payload rides on a series + stack of pier-lateral, housing-beam and bearing-radial springs. + k_eff = series(k_pier, k_ra_beam, k_dec_beam, k_ra_brg, k_dec_brg) + f_n = (1/2pi) * sqrt(k_eff / m_eff) + + 2. Overhung rocking (physical cross-check): the payload swings on the *angular* + compliance of the same beams/bearings at lever L_cg. Because the bearing + spans are short, this rotational mode is the true first mode and comes out + lower than the bounce mode -- so the honest verdict is taken on the minimum + of the two. + +We run a counterweight-FREE case (light payload) and a counterweighted case +(payload + counterweights), the latter being the lower-frequency case. +""" + +from __future__ import annotations + +import math + +from . import params as P +from . import stiffness as S +from .budget import BudgetResult, verdict_from_sf + +_TWO_PI = 2.0 * math.pi + + +# -------------------------------------------------------------------------- +# Stiffness terms (reuse stiffness.py; add the pier). +# -------------------------------------------------------------------------- +def pier_lateral_stiffness() -> float: + """Concrete pier as a lateral cantilever: k = 3*E*I/H^3 (N/m). + + Solid circular section I = pi*D^4/64, height = exposed pier height. + """ + mat = P.PIER.material + I = math.pi * P.PIER.diameter_m ** 4 / 64.0 + return 3.0 * mat.E * I / P.PIER.height_above_m ** 3 + + +def _series(*ks: float) -> float: + return 1.0 / sum(1.0 / k for k in ks) + + +def effective_stiffness(ra_housing: P.Housing, dec_housing: P.Housing) -> float: + """Series translational stiffness of the whole head on the pier (N/m).""" + k_pier = pier_lateral_stiffness() + k_ra_beam = S.beam_translational_stiffness_N_per_m(ra_housing) + k_dec_beam = S.beam_translational_stiffness_N_per_m(dec_housing) + k_ra_brg = S.bearing_pair_stiffness_N_per_m(S.K_RA_BEARING) + k_dec_brg = S.bearing_pair_stiffness_N_per_m(S.K_DEC_BEARING) + return _series(k_pier, k_ra_beam, k_dec_beam, k_ra_brg, k_dec_brg) + + +def effective_mass(ota: P.OTA, counterweighted: bool) -> float: + """Modal mass (kg): payload alone, or payload + counterweights.""" + m = S.payload_mass(ota) + if counterweighted: + m += P.COUNTERWEIGHTS.weights_available_kg + return m + + +def first_mode_hz(ota: P.OTA, ra_housing: P.Housing, dec_housing: P.Housing, + counterweighted: bool = False) -> float: + """Translational bounce mode f_n = (1/2pi) sqrt(k_eff/m_eff) (Hz).""" + k = effective_stiffness(ra_housing, dec_housing) + m = effective_mass(ota, counterweighted) + return math.sqrt(k / m) / _TWO_PI + + +# -------------------------------------------------------------------------- +# Rocking cross-check (angular version of the same beam/bearing model). +# -------------------------------------------------------------------------- +def beam_angular_stiffness(housing: P.Housing) -> float: + """M/theta for a box cantilever under an end moment: E*I/L (N.m/rad).""" + return housing.material.E * S.box_section_I(housing) / housing.depth_m + + +def bearing_angular_stiffness(span: float, k: float) -> float: + """M/theta for a bearing pair reacting a moment couple: k*span^2/2 (N.m/rad).""" + return k * span ** 2 / 2.0 + + +def support_angular_stiffness(ra_housing: P.Housing, dec_housing: P.Housing) -> float: + """Series rotational stiffness of the overhung load path (N.m/rad).""" + return _series( + beam_angular_stiffness(dec_housing), + beam_angular_stiffness(ra_housing), + bearing_angular_stiffness(dec_housing.depth_m, S.K_DEC_BEARING), + bearing_angular_stiffness(ra_housing.depth_m, S.K_RA_BEARING), + ) + + +def rocking_mode_hz(ota: P.OTA, ra_housing: P.Housing, dec_housing: P.Housing) -> float: + """Overhung rocking mode of the payload about the support (Hz). + + J = m * L_cg^2 (payload as a point mass at its gravity lever). + """ + k_theta = support_angular_stiffness(ra_housing, dec_housing) + J = S.payload_mass(ota) * S.cg_lever_m(ota) ** 2 + return math.sqrt(k_theta / J) / _TWO_PI + + +def governing_first_mode_hz(ota: P.OTA, ra_housing: P.Housing, + dec_housing: P.Housing, counterweighted: bool = False) -> float: + """The honest first mode = lower of the bounce and rocking modes (Hz).""" + return min(first_mode_hz(ota, ra_housing, dec_housing, counterweighted), + rocking_mode_hz(ota, ra_housing, dec_housing)) + + +# -------------------------------------------------------------------------- +# Budget +# -------------------------------------------------------------------------- +def evaluate(ota_key: str = "MN78") -> BudgetResult: + ota = P.OTA_CASES[ota_key] + # Bold 12 mm housings are the recommended build; use them for the head beams. + ra_h, dec_h = P.RA_HOUSING_12MM, P.DEC_HOUSING_12MM + + k_eff = effective_stiffness(ra_h, dec_h) + k_pier = pier_lateral_stiffness() + + f_bounce_free = first_mode_hz(ota, ra_h, dec_h, counterweighted=False) + f_bounce_cw = first_mode_hz(ota, ra_h, dec_h, counterweighted=True) + f_rock = rocking_mode_hz(ota, ra_h, dec_h) + + # Governing = lowest of every mode/mass case we examined. + f_governing = min(f_bounce_free, f_bounce_cw, f_rock) + tgt = P.NATURAL_FREQ_TARGET_HZ + sf = f_governing / tgt + verdict = verdict_from_sf(sf, pass_at=1.5, marginal_at=1.0) + + res = BudgetResult( + key="dynamics", + title="First natural frequency (structural first mode)", + verdict=verdict, + headline=( + f"{ota.name}: governing first mode {f_governing:.0f} Hz " + f"(rocking {f_rock:.0f} Hz, bounce {f_bounce_cw:.0f}-{f_bounce_free:.0f} Hz) " + f"vs {tgt:.0f} Hz target -- SF {sf:.1f}, clear of the <2 Hz wind and " + f"1-5 Hz servo bands." + ), + target=f"First mode > {tgt:.0f} Hz (Hedrick), SF>=1.5 to PASS; must clear 1-5 Hz servo", + safety_factor=sf, + ) + res.add("Pier lateral stiffness", k_pier, "N/m", "concrete cantilever 3EI/H^3") + res.add("Head series stiffness k_eff", k_eff, "N/m", "pier+beams+bearings in series") + res.add("Payload mass (CW-free)", effective_mass(ota, False), "kg") + res.add("Payload + counterweights", effective_mass(ota, True), "kg") + res.add("Bounce mode, CW-free", f_bounce_free, "Hz", "translational") + res.add("Bounce mode, counterweighted", f_bounce_cw, "Hz", "heavier -> lower f") + res.add("Rocking mode (governing)", f_rock, "Hz", "overhung on bearing angular stiffness") + res.add("Governing first mode", f_governing, "Hz", "min of all modes") + res.add("Governing / target (SF)", sf, "x", verdict.value) + res.add("Wind-gust excitation band", 2.0, "Hz", "must stay below f_n") + res.add("Servo/guide bandwidth", 5.0, "Hz", "must stay below f_n") + + res.assumptions = [ + "Pier modelled as a solid concrete lateral cantilever (I = pi*D^4/64); its own " + "mass and soil/footing compliance are neglected (stiffer, non-conservative for " + "the pier term but it is not governing).", + "Bearing radial stiffness 6008~250 N/um, 6006~180 N/um (ASSUMED, shared with " + "stiffness.py); rocking uses the derived angular stiffness k*span^2/2.", + "Modal mass = payload (+ 12.5 kg counterweights); head/drive masses and pier " + "participation neglected -> the true modes are slightly lower.", + "Rocking inertia J = m*L_cg^2 with L_cg the same gravity lever as stiffness.py " + "(233 mm CG + 50 mm saddle); point-mass approximation.", + "12 mm bold housings used for the head beams (recommended build); beam terms are " + "negligible vs pier and bearing terms either way.", + "Verdict taken on the lowest mode found (rocking), not the higher bounce mode -- " + "the honest first mode.", + ] + return res + + +if __name__ == "__main__": + r = evaluate() + print(r.verdict.symbol, r.headline) + print(r.markdown_table()) + for a in r.assumptions: + print(" -", a) diff --git a/design/mechanical/calc/enclosure.py b/design/mechanical/calc/enclosure.py new file mode 100644 index 0000000..e6037c2 --- /dev/null +++ b/design/mechanical/calc/enclosure.py @@ -0,0 +1,244 @@ +""" +Roll-off roof mechanics — can the drive move the roof against rolling friction +AND the 35 mph emergency-close gust within the open/close time, and what does the +closed-roof snow load do to the structure? + +Two questions, one honest answer each: + +Drive +----- +The moving element is the framed roof panel (P.ENCLOSURE.roof_mass_kg) on level +track. The tractive force the drive must overcome is: + + F_move = mu * m * g (rolling resistance, level track) + + 0.5*rho*V^2*Cd*A (along-track wind drag on the roof end/fascia) + +evaluated at the 35 mph gust (P.ENV.wind_gust_close_ms) because the roof MUST be +able to *close* against the worst wind it is allowed to be open in. Drive torque +at the wheel is F_move * wheel_radius. A residential garage-door-opener-class +chain drive (~500 N pull) is the reference capacity. The finding: at 35 mph the +wind drag alone is ~60% of the required tractive force — the drive is sized by +wind, not by the roof's own weight — so the margin over a garage-door opener is +only ~2x, sensitive to the roof's frontal height. + +Snow +---- +The CLOSED flat roof carries the full ground snow load: + + F_snow = P.ENV.ground_snow_load_Pa * roof_area + +At 25 psf over a 3x3 m roof this is ~10.8 kN (~1.1 tonne), ~6x the roof's own +dead weight — the snow case, not the wind case, is the structural design driver +for the roof panel and its supports. A FLAT roll-off roof must carry all of it; a +SLOPED, slippery roof sheds most of it (ASCE 7 slope factor Cs -> ~0 for a steep +metal roof), which is a strong argument for a pitched roof or a positive snow +interlock. A fully snow-laden roof also cannot be driven (rolling force exceeds +the opener), so the roof must never be commanded open under snow. + +Headline verdict is on the DRIVE (the mechanism this module can size from params); +the snow load is reported as the governing structural finding + interlock need, +the same way bearings.py PASSes on load capacity while flagging the real driver. +""" + +from __future__ import annotations + +from . import params as P +from . import units as u +from .budget import BudgetResult, Verdict, verdict_from_sf + +# -------------------------------------------------------------------------- +# Modelled constants (params.py is silent on roof drive geometry). +# -------------------------------------------------------------------------- +# Rolling-resistance coefficient, wheel-on-track. Ideal steel-on-steel is +# ~0.001-0.005, but a real roll-off with V-groove wheels, misalignment, seal drag +# and dirt runs far higher; 0.05 is a conservative ASSUMED value. ASSUMED. +ROLLING_MU = 0.05 + +# Drive-wheel (or friction/chain sprocket) pitch radius. ASSUMED (stated: 0.05 m). +DRIVE_WHEEL_RADIUS_M = 0.05 + +# Frontal height of the roof structure (fascia + rafter depth) that presents an +# along-track area to a head/tail wind while the roof is moving. The repo does not +# specify roof section; 0.30 m is an ASSUMED framed-panel depth. ASSUMED. +ROOF_FASCIA_HEIGHT_M = 0.30 + +# Bluff flat-panel drag coefficient for the roof end. ENV.cd_cylinder (1.1) is for +# a round tube; a flat/square bluff face is higher (~1.2-2.0). 1.3 ASSUMED. +CD_ROOF_FLAT = 1.3 + +# Reference drive capacity: a residential garage-door-opener-class chain drive +# pulls ~500 N. Conservative COTS figure; commercial roof drives are stronger. +# ASSUMED (reference capacity). +DRIVE_FORCE_CAP_N = 500.0 + +# Comfortable / safe maximum roof traverse speed (people & pinch hazard). The +# required speed must sit well under this. 0.30 m/s ASSUMED. +FEASIBLE_ROOF_SPEED_MS = 0.30 + +# Pass gate on the drive safety factor (capacity / required tractive force). +DRIVE_SF_PASS = 2.0 + + +# -------------------------------------------------------------------------- +# Pure helpers (the tests call these). +# -------------------------------------------------------------------------- +def roof_area_m2() -> float: + """Plan area of the roof (m^2), from the assumed footprint in params.""" + return P.ENCLOSURE.roof_span_m * P.ENCLOSURE.roof_length_m + + +def roof_frontal_area_m2() -> float: + """Along-track frontal area the wind pushes on while the roof moves (m^2).""" + return P.ENCLOSURE.roof_span_m * ROOF_FASCIA_HEIGHT_M + + +def roof_rolling_force_N(mass_kg: float | None = None) -> float: + """Rolling resistance on level track: mu * m * g (N).""" + m = P.ENCLOSURE.roof_mass_kg if mass_kg is None else mass_kg + return ROLLING_MU * m * u.G0 + + +def roof_wind_drag_N(wind_ms: float) -> float: + """Along-track wind drag on the roof end/fascia: 0.5*rho*V^2*Cd*A (N).""" + return 0.5 * P.SITE.air_density * wind_ms ** 2 * CD_ROOF_FLAT * roof_frontal_area_m2() + + +def roof_move_force_N(wind_ms: float) -> float: + """ + Total tractive force to move the roof at a given wind speed (N): + rolling resistance + along-track wind drag. At wind=0 this is rolling only. + """ + return roof_rolling_force_N() + roof_wind_drag_N(wind_ms) + + +def roof_drive_torque_Nm(wind_ms: float) -> float: + """Drive torque at the wheel to produce roof_move_force_N: F * wheel_radius.""" + return roof_move_force_N(wind_ms) * DRIVE_WHEEL_RADIUS_M + + +def roof_required_speed_ms() -> float: + """Average roof speed to traverse roof_length within open_time (m/s).""" + return P.ENCLOSURE.roof_length_m / P.ENCLOSURE.open_time_s + + +def roof_snow_load_N() -> float: + """Total closed-roof snow force = ground snow pressure * roof plan area (N).""" + return P.ENV.ground_snow_load_Pa * roof_area_m2() + + +def snow_laden_rolling_force_N() -> float: + """Rolling force to move the roof with the full design snow load sitting on it: + mu * (m_roof + m_snow) * g (N). If this exceeds the drive, the roof cannot be + opened while snow-laden -> a snow interlock is mandatory.""" + m_snow = roof_snow_load_N() / u.G0 + return roof_rolling_force_N(P.ENCLOSURE.roof_mass_kg + m_snow) + + +# -------------------------------------------------------------------------- +# Budget +# -------------------------------------------------------------------------- +def evaluate() -> BudgetResult: + gust = P.ENV.wind_gust_close_ms # 35 mph emergency-close + + f_roll = roof_rolling_force_N() + f_wind = roof_wind_drag_N(gust) + f_move = roof_move_force_N(gust) + torque = roof_drive_torque_Nm(gust) + wind_frac = f_wind / f_move + + v_req = roof_required_speed_ms() + speed_ok = v_req < FEASIBLE_ROOF_SPEED_MS + + sf_drive = DRIVE_FORCE_CAP_N / f_move + + # Snow (structural + operational). + snow_N = roof_snow_load_N() + snow_mass = snow_N / u.G0 + roof_weight = u.weight_N(P.ENCLOSURE.roof_mass_kg) + snow_over_dead = snow_N / roof_weight + f_snow_roll = snow_laden_rolling_force_N() + snow_stalls_drive = f_snow_roll > DRIVE_FORCE_CAP_N + + # Module verdict is the DRIVE feasibility (the mechanism sizeable from params); + # a too-slow-to-be-safe roof would degrade it. Snow is the loud structural + # finding carried in the headline/assumptions (no roof section in params to + # certify against — see notes), mirroring bearings.py. + verdict = verdict_from_sf(sf_drive, pass_at=DRIVE_SF_PASS, marginal_at=1.0) + if not speed_ok and verdict is Verdict.PASS: + verdict = Verdict.MARGINAL + + res = BudgetResult( + key="enclosure", + title="Roll-off roof drive + snow load", + verdict=verdict, + headline=( + f"Drive {verdict.value}: moving the {P.ENCLOSURE.roof_mass_kg:.0f} kg roof against rolling + " + f"the 35 mph close gust needs {f_move:.0f} N ({torque:.1f} Nm @ {DRIVE_WHEEL_RADIUS_M*100:.0f} cm " + f"wheel), SF {sf_drive:.1f} vs a ~{DRIVE_FORCE_CAP_N:.0f} N garage-door-class drive — and wind is " + f"{wind_frac*100:.0f}% of that load, so the drive is WIND-sized. FINDING: the closed flat roof " + f"carries {snow_N/1000:.1f} kN of snow (~{snow_over_dead:.1f}x its dead weight) — the snow case is " + f"the roof's structural design driver; a sloped roof sheds it, and a snow-laden roof " + f"{'CANNOT' if snow_stalls_drive else 'can'} be driven -> snow interlock required." + ), + target=f"Roof drive overcomes rolling + 35 mph gust with SF >= {DRIVE_SF_PASS:.0f} and traverses within open_time", + safety_factor=sf_drive, + ) + + # Drive. + res.add("Roof mass", P.ENCLOSURE.roof_mass_kg, "kg", "ASSUMED (params)") + res.add("Rolling resistance force", f_roll, "N", f"mu={ROLLING_MU} * m * g") + res.add("Wind drag @ 35 mph gust", f_wind, "N", f"0.5*rho*V^2*Cd*A, Cd={CD_ROOF_FLAT}") + res.add("Frontal area (wind)", roof_frontal_area_m2(), "m^2", f"span x {ROOF_FASCIA_HEIGHT_M} m fascia") + res.add("Total tractive force", f_move, "N", "rolling + wind") + res.add("Wind fraction of tractive force", wind_frac * 100.0, "%", "drive is wind-sized") + res.add("Drive torque @ wheel", torque, "Nm", f"F * {DRIVE_WHEEL_RADIUS_M} m") + res.add("Drive capacity (reference)", DRIVE_FORCE_CAP_N, "N", "garage-door-opener class, ASSUMED") + res.add("Drive safety factor", sf_drive, "x", f"capacity / tractive (pass >= {DRIVE_SF_PASS:.0f})") + res.add("Required roof speed", v_req, "m/s", f"{P.ENCLOSURE.roof_length_m:.0f} m / {P.ENCLOSURE.open_time_s:.0f} s") + res.add("Feasible speed ceiling", FEASIBLE_ROOF_SPEED_MS, "m/s", f"speed ok = {speed_ok}") + res.add("Open time vs motor timeout", P.ENCLOSURE.open_time_s, "s", f"< {P.ENCLOSURE.motor_timeout_s:.0f} s timeout") + # Snow. + res.add("Ground snow pressure", P.ENV.ground_snow_load_Pa, "Pa", "25 psf, ASSUMED (params)") + res.add("Roof plan area", roof_area_m2(), "m^2", "span x length") + res.add("Closed-roof snow load", snow_N, "N", "pressure x area") + res.add("Snow mass on roof", snow_mass, "kg", "~1.1 tonne") + res.add("Snow / roof dead weight", snow_over_dead, "x", "snow dominates the structure") + res.add("Snow-laden rolling force", f_snow_roll, "N", f"mu*(m_roof+m_snow)*g {'> drive' if snow_stalls_drive else '< drive'}") + + res.assumptions = [ + f"Rolling resistance coefficient mu = {ROLLING_MU} (ASSUMED): far above ideal steel-on-steel " + "(~0.001-0.005) to cover V-groove wheels, misalignment, seal drag and grit — conservative.", + f"Drive-wheel radius {DRIVE_WHEEL_RADIUS_M} m (ASSUMED, stated). Drive torque = F * radius.", + f"Wind drag uses the 35 mph emergency-close gust (P.ENV.wind_gust_close_ms, SOURCED) on a frontal " + f"area = roof span x {ROOF_FASCIA_HEIGHT_M} m fascia height (ASSUMED) with a flat-panel Cd={CD_ROOF_FLAT} " + f"(ASSUMED; ENV.cd_cylinder=1.1 is for a round tube). Air density {P.SITE.air_density:.3f} kg/m^3 at " + "1800 m (DERIVED). The result is sensitive to the assumed fascia height: a taller roof profile pushes " + "the drive SF toward MARGINAL.", + f"Drive capacity {DRIVE_FORCE_CAP_N:.0f} N is a residential garage-door-opener-class chain drive " + "(ASSUMED reference); a commercial roof drive is stronger. FINDING: at 35 mph the WIND is " + f"{wind_frac*100:.0f}% of the tractive load, so the drive is sized by wind, not roof weight — a " + "positive close against the gust is the governing drive requirement.", + f"Required roof speed {v_req:.3f} m/s ({P.ENCLOSURE.roof_length_m:.0f} m in {P.ENCLOSURE.open_time_s:.0f} s) " + f"is well under the {FEASIBLE_ROOF_SPEED_MS:.2f} m/s comfort/safety ceiling (ASSUMED), and open_time " + f"{P.ENCLOSURE.open_time_s:.0f} s < {P.ENCLOSURE.motor_timeout_s:.0f} s motor timeout (SOURCED), so travel " + "time is not the constraint.", + f"SNOW (structural): the closed flat roof carries the full ground snow load " + f"({P.ENV.ground_snow_load_Pa/u.PSF_TO_PA:.0f} psf, ASSUMED) = {snow_N/1000:.1f} kN over {roof_area_m2():.0f} m^2, " + f"~{snow_over_dead:.1f}x the roof's own {roof_weight/1000:.1f} kN dead weight. The snow case, not wind, is " + "the roof panel/support design driver. A SLOPED, slippery roof sheds most of it (ASCE 7 slope factor " + "Cs -> ~0 for a steep metal roof); a FLAT roll-off roof must carry all of it.", + f"SNOW (operational): a fully snow-laden roof needs {f_snow_roll:.0f} N to roll, which " + f"{'EXCEEDS' if snow_stalls_drive else 'is within'} the {DRIVE_FORCE_CAP_N:.0f} N drive -> the roof must " + "never be commanded open under snow. A snow/ice interlock (or a pitched shedding roof) is required; the " + "safety monitor should hold the roof CLOSED while snow-loaded.", + "OPEN ITEM: params.py specifies no roof structural section, so the snow load here is reported as the " + "governing DESIGN LOAD, not certified against a computed roof capacity — see notes (recommend a POS " + "Enclosure roof-structure entry).", + ] + return res + + +if __name__ == "__main__": + r = evaluate() + print(r.verdict.symbol, r.headline) + print(r.markdown_table()) diff --git a/design/mechanical/calc/encoder.py b/design/mechanical/calc/encoder.py new file mode 100644 index 0000000..1a79c11 --- /dev/null +++ b/design/mechanical/calc/encoder.py @@ -0,0 +1,202 @@ +""" +Tracking-error (pointing) budget — the SHARP proof of the whole mount. + +The repo's headline aspiration is "< 1 arcsec RMS" tracking (Hedrick review, +``P.TRACKING_RMS_TARGET_ARCSEC``). This module asks the uncomfortable question: +does the *as-specified* encoder chain actually reach it? It does not — and that +is the point of the proof. + +Two schemes are compared: + +Baseline (as specified in the Build Package) + A dual-encoder scheme: a fine AMT103 on the *motor* shaft (8192 PPR x100 => + ~1.58" to the axis) plus a cheap AS5600 12-bit absolute "on-axis" chip + (~316" resolution). The servo can only close its loop on the motor encoder, + because the AS5600's quantisation (~91" RMS) is 90x the entire error budget + and is fit only for coarse homing, not tracking. So everything the motor + encoder cannot *see* leaks straight into pointing: + * harmonic-drive periodic error between motor and axis (PE residual), and + * structural / drivetrain flexure between motor and star. + Baseline RMS = RSS(motor_quant, PE_residual, flexure). + +Proposed (v2) + Close the servo loop on a genuine on-axis high-resolution *absolute* ring + (Renishaw RESA-class, ``P.ENC_AXIS_RESA``, sub-arcsec LSB). Sitting on the + axis, it observes and corrects the harmonic PE and every drivetrain/mount + flexure *upstream* of the axis; only the servo following error and the + residual OTA/mirror-cell flexure *downstream* of the ring survive. + Proposed RMS = RSS(resa_quant, servo_following, residual_flexure). + +Headline decision: the baseline chain FAILS < 1 arcsec (~5 arcsec RMS); an +on-axis high-resolution absolute encoder is REQUIRED to reach sub-arcsecond. +""" + +from __future__ import annotations + +import math + +from . import params as P +from .budget import BudgetResult, verdict_from_sf + +# -------------------------------------------------------------------------- +# Modelled error terms. None of these exist in params.py, so they are defined +# and tagged here (DERIVED/ASSUMED) and echoed into result.assumptions. +# -------------------------------------------------------------------------- + +# Harmonic-drive periodic-error residual that the MOTOR encoder cannot observe. +# ASSUMED 4.0" RMS — consistent with the CSF units' 6-8" peak repeatability +# (P.RA_DRIVE.repeatability_arcsec = 6", P.DEC_DRIVE = 8"): a quasi-sinusoidal +# PE of ~6-8" peak-to-peak lands near ~4" RMS once the strain-wave 2/rev term +# and gravity-dependent lost motion are folded in. ASSUMED. +PE_RESIDUAL_ARCSEC = 4.0 + +# Drivetrain + mount flexure between the motor encoder and the star, none of it +# seen by a motor-side encoder. From a stated ~3" structural flexure budget +# (bearing preload, housing wall, harmonic wind-up). ASSUMED. +FLEXURE_ARCSEC = 3.0 + +# On-axis closed-loop servo following error once the loop is closed on a +# high-resolution axis encoder (finite loop bandwidth vs wind/seeing input). +# ASSUMED 0.2" RMS — routine for a stiff sub-kHz servo with a fine axis sensor. +SERVO_FOLLOWING_ARCSEC = 0.2 + +# Residual flexure that even an on-axis ring CANNOT correct: the OTA / focuser / +# mirror-cell flex that lies *downstream* of the encoder ring, between the axis +# and the focal plane. Much smaller than the full 3" chain flexure because the +# ring corrects everything upstream of the axis. ASSUMED 0.5" RMS. +FLEXURE_RESIDUAL_ARCSEC = 0.5 + + +# -------------------------------------------------------------------------- +# Pure helpers (the tests call these directly). +# -------------------------------------------------------------------------- + + +def quant_rms_arcsec(enc: P.Encoder) -> float: + """RMS error of an ideal uniform quantiser = LSB / sqrt(12) (arcsec).""" + return enc.resolution_arcsec / math.sqrt(12.0) + + +def _rss(*terms: float) -> float: + return math.sqrt(sum(t * t for t in terms)) + + +def tracking_components(scheme: str = "baseline") -> dict[str, float]: + """Return the RMS error terms (arcsec) that RSS into the tracking budget.""" + if scheme == "baseline": + return { + "quant": quant_rms_arcsec(P.ENC_MOTOR_AMT103), # servo on motor encoder + "pe_residual": PE_RESIDUAL_ARCSEC, # unseen by motor encoder + "flexure": FLEXURE_ARCSEC, # unseen by motor encoder + } + if scheme == "proposed": + return { + "quant": quant_rms_arcsec(P.ENC_AXIS_RESA), # servo on on-axis ring + "servo": SERVO_FOLLOWING_ARCSEC, # finite-bandwidth following + "flexure": FLEXURE_RESIDUAL_ARCSEC, # OTA flex downstream of ring + } + raise ValueError(f"unknown scheme {scheme!r} (want 'baseline' or 'proposed')") + + +def tracking_rms_arcsec(scheme: str = "baseline") -> float: + """Total pointing/tracking error (arcsec RMS) for a scheme, via RSS.""" + return _rss(*tracking_components(scheme).values()) + + +# -------------------------------------------------------------------------- +# Budget +# -------------------------------------------------------------------------- + + +def evaluate(scheme: str = "compare") -> BudgetResult: + target = P.TRACKING_RMS_TARGET_ARCSEC + + base = tracking_rms_arcsec("baseline") + prop = tracking_rms_arcsec("proposed") + base_sf = target / base # >1 => meets target with margin + prop_sf = target / prop + + base_c = tracking_components("baseline") + prop_c = tracking_components("proposed") + + base_verdict = verdict_from_sf(base_sf, pass_at=1.5, marginal_at=1.0) + prop_verdict = verdict_from_sf(prop_sf, pass_at=1.5, marginal_at=1.0) + + if scheme == "baseline": + verdict, sf = base_verdict, base_sf + headline = ( + f"Baseline dual-encoder chain: {base:.2f} arcsec RMS vs {target:.1f} arcsec " + f"target (SF {base_sf:.2f}) — FAILS; the motor encoder cannot see harmonic " + f"PE or mount flexure." + ) + elif scheme == "proposed": + verdict, sf = prop_verdict, prop_sf + headline = ( + f"On-axis RESA ring closed loop: {prop:.2f} arcsec RMS vs {target:.1f} arcsec " + f"target (SF {prop_sf:.2f}) — MEETS sub-arcsecond." + ) + elif scheme == "compare": + # The design AS SPECIFIED is the baseline, so the honest module verdict + # is the baseline's: it FAILS, and the proposed ring is the remedy. + verdict, sf = base_verdict, base_sf + headline = ( + f"Baseline dual-encoder chain reaches only {base:.2f} arcsec RMS, FAILING the " + f"{target:.1f} arcsec target; the on-axis RESA ring reaches {prop:.2f} arcsec RMS. " + f"An on-axis high-resolution absolute encoder is REQUIRED to reach sub-arcsecond." + ) + else: + raise ValueError(f"unknown scheme {scheme!r}") + + res = BudgetResult( + key="encoder", + title="Tracking-error (pointing) budget", + verdict=verdict, + headline=headline, + target=f"Tracking error < {target:.1f} arcsec RMS (P.TRACKING_RMS_TARGET_ARCSEC)", + safety_factor=sf, + ) + + # --- Encoder resolutions (LSB to the axis) --- + res.add("AMT103 motor-side resolution (to axis)", P.ENC_MOTOR_AMT103.resolution_arcsec, + "arcsec/LSB", "8192 PPR x100 upstream ratio") + res.add("AS5600 on-axis resolution", P.ENC_AXIS_AS5600.resolution_arcsec, + "arcsec/LSB", "12-bit => quant RMS 91\", homing-grade only") + res.add("RESA on-axis resolution (proposed)", P.ENC_AXIS_RESA.resolution_arcsec, + "arcsec/LSB", "absolute ring, sub-arcsec") + + # --- Baseline chain (servo locked to the motor encoder) --- + res.add("Baseline: motor quant RMS", base_c["quant"], "arcsec", "res/sqrt(12)") + res.add("Baseline: harmonic PE residual", base_c["pe_residual"], "arcsec", "unseen by motor enc") + res.add("Baseline: drivetrain/mount flexure", base_c["flexure"], "arcsec", "unseen by motor enc") + res.add("Baseline total tracking RMS", base, "arcsec", "RSS of the three") + res.add("Baseline margin (target/achieved)", base_sf, "x", "<1 => fails") + + # --- Proposed chain (servo locked to the on-axis ring) --- + res.add("Proposed: RESA quant RMS", prop_c["quant"], "arcsec", "res/sqrt(12)") + res.add("Proposed: servo following error", prop_c["servo"], "arcsec", "on-axis closed loop") + res.add("Proposed: residual OTA flexure", prop_c["flexure"], "arcsec", "downstream of ring") + res.add("Proposed total tracking RMS", prop, "arcsec", "RSS of the three") + res.add("Proposed margin (target/achieved)", prop_sf, "x", ">1 => meets") + + res.assumptions = [ + "Quantisation RMS = LSB/sqrt(12) (ideal uniform quantiser).", + f"Baseline servo closes on the MOTOR encoder ({P.ENC_MOTOR_AMT103.resolution_arcsec:.2f}\" " + f"to axis); the AS5600's {quant_rms_arcsec(P.ENC_AXIS_AS5600):.0f}\" quant RMS makes it " + f"a homing reference only, so harmonic PE and mount flexure leak into pointing. SOURCED chain.", + f"Harmonic PE residual = {PE_RESIDUAL_ARCSEC:.1f}\" RMS (ASSUMED), consistent with CSF " + f"repeatability P.RA_DRIVE={P.RA_DRIVE.repeatability_arcsec:.0f}\" / " + f"P.DEC_DRIVE={P.DEC_DRIVE.repeatability_arcsec:.0f}\".", + f"Drivetrain + mount flexure = {FLEXURE_ARCSEC:.1f}\" RMS (ASSUMED, stated structural budget), " + f"entirely upstream of the axis and thus invisible to a motor encoder.", + f"Proposed on-axis ring corrects everything upstream of the axis; only servo following " + f"({SERVO_FOLLOWING_ARCSEC:.1f}\" RMS, ASSUMED) and residual OTA/focuser flexure " + f"({FLEXURE_RESIDUAL_ARCSEC:.1f}\" RMS, ASSUMED) downstream of the ring remain.", + "Terms combine in RSS (independent, zero-mean error sources).", + ] + return res + + +if __name__ == "__main__": + r = evaluate() + print(r.verdict.symbol, r.headline) + print(r.markdown_table()) diff --git a/design/mechanical/calc/params.py b/design/mechanical/calc/params.py new file mode 100644 index 0000000..0c2e6f5 --- /dev/null +++ b/design/mechanical/calc/params.py @@ -0,0 +1,407 @@ +""" +NIGHTWATCH mechanical-design parameters — the single source of truth. + +Every budget module imports from here, so the numbers can no longer disagree +file-to-file the way they do across the repo today (MN76 vs MN78 mass, 2048 vs +8192 PPR, 10/15/20 deg horizon limits, temp in C vs F ...). + +All values are SI (m, kg, s, N, Pa, K, rad). Imperial/astronomy figures from +the docs are converted on the way in via ``units``. Each field is tagged in +``PROVENANCE`` as: + + S = sourced (stated in a repo file — path given) + D = derived (computed from sourced values) + A = assumed (repo silent — a stated engineering assumption) + +The three ASSUMED structural load cases (survival wind, snow, seismic) and the +DGX thermal figure are the repo's governing voids; they are labelled so nothing +masquerades as measured or specified data. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from . import units as u + +# ========================================================================== +# Materials (E = Young's modulus Pa, sigma_y = yield Pa, rho = kg/m^3, +# cte = 1/K, Sut for fatigue where relevant) +# ========================================================================== + + +@dataclass(frozen=True) +class Material: + name: str + E: float # Young's modulus, Pa + sigma_y: float # yield strength, Pa + rho: float # density, kg/m^3 + cte: float # coeff. thermal expansion, 1/K + G: float = 0.0 # shear modulus, Pa (0 => derive as E/2.6) + + @property + def shear_modulus(self) -> float: + return self.G if self.G > 0 else self.E / 2.6 + + +MATERIALS: dict[str, Material] = { + # 6061-T6 aluminium — the frame material specified throughout the repo. + "6061-T6": Material("6061-T6 aluminium", E=68.9e9, sigma_y=276e6, rho=2700.0, cte=23.6e-6, G=26.0e9), + # 303 stainless — the counterweight shaft. + "303-SS": Material("303 stainless", E=193e9, sigma_y=240e6, rho=8000.0, cte=17.3e-6, G=77.0e9), + # A36 mild steel — pier top plate / steel-pier alternative. + "A36-steel": Material("A36 steel", E=200e9, sigma_y=250e6, rho=7850.0, cte=11.7e-6, G=79.3e9), + # 4000 psi fibre-reinforced concrete — the pier. Ec = 57000*sqrt(f'c[psi]) psi. + "concrete-4ksi": Material("4000 psi concrete", E=24.86e9, sigma_y=27.6e6, rho=2400.0, cte=10.0e-6, G=10.4e9), +} + + +# ========================================================================== +# Optical tube assemblies — the unresolved MN76 / MN78 fork, both carried. +# ========================================================================== + + +@dataclass(frozen=True) +class OTA: + name: str + aperture_m: float + focal_length_m: float + f_ratio: float + mass_kg: float + tube_length_m: float + tube_od_m: float # outer tube diameter (for wind area & inertia) + central_obstruction: float # fraction of aperture diameter + + @property + def cg_from_saddle_m(self) -> float: + """ + CG height above the dovetail saddle plane. A closed Mak-Newt tube is + roughly uniform, so CG ~ mid-tube; the saddle sits ~1/3 up the tube for + balance, giving a modest lever arm. DERIVED (geometric estimate). + """ + return 0.5 * self.tube_length_m - (self.tube_length_m / 3.0) + + +# Case A: MN76 as specified in NIGHTWATCH_Build_Package.md (primary spec table). +MN76 = OTA( + name="Intes-Micro MN76 (f/6)", + aperture_m=u.mm(178.0), + focal_length_m=u.mm(1068.0), + f_ratio=6.0, + mass_kg=9.0, + tube_length_m=u.mm(700.0), + tube_od_m=u.mm(205.0), + central_obstruction=0.25, +) + +# Case B: MN78 as specified in docs/INTES_MICRO_HISTORY.md ("selected", f/8, 14 kg). +MN78 = OTA( + name="Intes-Micro MN78 (f/8)", + aperture_m=u.mm(180.0), + focal_length_m=u.mm(1440.0), + f_ratio=8.0, + mass_kg=14.0, + tube_length_m=u.mm(1400.0), + tube_od_m=u.mm(210.0), + central_obstruction=0.134, +) + +OTA_CASES: dict[str, OTA] = {"MN76": MN76, "MN78": MN78} + + +@dataclass(frozen=True) +class ImagingTrain: + """Lumped rear accessories: camera + ADC + Powermate + diagonal + focuser.""" + mass_kg: float = 4.0 # Musser load analysis (lumped) + offset_behind_tube_m: float = u.mm(150.0) # rear stack lever from tube back, DERIVED + + +IMAGING_TRAIN = ImagingTrain() + + +# ========================================================================== +# Harmonic (strain-wave) drives — Harmonic Drive LLC CSF series. +# ========================================================================== + + +@dataclass(frozen=True) +class HarmonicDrive: + name: str + ratio: float + rated_torque_Nm: float # continuous + peak_torque_Nm: float # momentary + repeatability_arcsec: float + backlash_arcmin: float + mass_kg: float + bore_m: float + + +RA_DRIVE = HarmonicDrive( + name="CSF-32-100-2A-GR", ratio=100.0, rated_torque_Nm=127.0, peak_torque_Nm=343.0, + repeatability_arcsec=6.0, backlash_arcmin=1.0, mass_kg=1.6, bore_m=u.mm(80.0), +) +DEC_DRIVE = HarmonicDrive( + name="CSF-25-80-2A-GR", ratio=80.0, rated_torque_Nm=70.0, peak_torque_Nm=186.0, + repeatability_arcsec=8.0, backlash_arcmin=1.0, mass_kg=1.0, bore_m=u.mm(64.0), +) + + +# ========================================================================== +# Motor / driver train (NEMA17 + planetary + harmonic). +# ========================================================================== + + +@dataclass(frozen=True) +class MotorDrive: + step_angle_deg: float = 1.8 # NEMA17 full step + microsteps: int = 16 # TMC5160 + planetary_ratio: float = 27.0 # StepperOnline planetary gearhead + holding_torque_Nm: float = 0.45 # NEMA17 typical + irun_A: float = 1.5 # TMC5160 run current + igoto_A: float = 2.0 # TMC5160 goto current + slew_rate_dps: float = 4.0 # firmware Config.h goto rate + accel_dps2: float = 2.0 # firmware Config.h goto accel + + +MOTOR = MotorDrive() + + +def steps_per_degree(drive: HarmonicDrive, motor: MotorDrive = MOTOR) -> float: + """Full mechanical resolution to the axis (steps/deg). DERIVED.""" + steps_per_motor_rev = (360.0 / motor.step_angle_deg) * motor.microsteps + axis_ratio = motor.planetary_ratio * drive.ratio + return steps_per_motor_rev * axis_ratio / 360.0 + + +# ========================================================================== +# Encoder chains — the crux of the sub-arcsecond contradiction. +# ========================================================================== + + +@dataclass(frozen=True) +class Encoder: + name: str + counts_per_rev_native: float # counts per revolution of the thing it's on + on_axis: bool # True => already on the telescope axis + upstream_ratio: float = 1.0 # gear ratio between encoder and axis (motor-side) + + @property + def axis_counts_per_rev(self) -> float: + return self.counts_per_rev_native * (self.upstream_ratio if not self.on_axis else 1.0) + + @property + def resolution_arcsec(self) -> float: + return u.ARCSEC_PER_REV / self.axis_counts_per_rev + + +# Baseline dual-encoder scheme from the Build Package / Hedrick review. +ENC_MOTOR_AMT103 = Encoder("AMT103-V motor-side", counts_per_rev_native=8192.0, on_axis=False, upstream_ratio=100.0) +ENC_AXIS_AS5600 = Encoder("AS5600 12-bit on-axis", counts_per_rev_native=4096.0, on_axis=True) + +# Bold v2 proposal: on-axis absolute tape ring (Renishaw RESA-class) sized for sub-arcsec. +# A ~200 mm ring (circumference pi*200 = 628 mm) at ~15 um signal pitch gives ~41,900 +# periods, and Renishaw x5000-class interpolation yields ~2.3e8 counts/rev (~0.0055 +# arcsec/LSB). We use that delivered figure and let the encoder budget check it. +ENC_AXIS_RESA = Encoder("Renishaw RESA ring on-axis (proposed)", counts_per_rev_native=234_000_000.0, on_axis=True) + + +# ========================================================================== +# Frame housings (6061-T6, CNC). Dimensions from the Build Package table. +# ========================================================================== + + +@dataclass(frozen=True) +class Housing: + name: str + outer_x_m: float + outer_y_m: float + depth_m: float # along the axis => sets bearing separation + wall_m: float # structural wall thickness + material_key: str = "6061-T6" + + @property + def material(self) -> Material: + return MATERIALS[self.material_key] + + +# Baseline walls 8 mm (Hedrick min); bold review raised to 12 mm. +RA_HOUSING = Housing("RA housing", outer_x_m=u.inch(8), outer_y_m=u.inch(8), depth_m=u.inch(3), wall_m=u.mm(8)) +DEC_HOUSING = Housing("DEC housing", outer_x_m=u.inch(6), outer_y_m=u.inch(6), depth_m=u.inch(2.5), wall_m=u.mm(8)) +RA_HOUSING_12MM = Housing("RA housing (12 mm wall)", outer_x_m=u.inch(8), outer_y_m=u.inch(8), depth_m=u.inch(3), wall_m=u.mm(12)) +DEC_HOUSING_12MM = Housing("DEC housing (12 mm wall)", outer_x_m=u.inch(6), outer_y_m=u.inch(6), depth_m=u.inch(2.5), wall_m=u.mm(12)) + + +# ========================================================================== +# Bearings (deep-groove values; repo labels them "angular contact" — flagged). +# C = dynamic load rating (N), C0 = static (N). +# ========================================================================== + + +@dataclass(frozen=True) +class Bearing: + name: str + bore_m: float + od_m: float + C_dynamic_N: float + C0_static_N: float + + +BRG_RA_6008 = Bearing("6008-2RS (RA output)", bore_m=u.mm(40), od_m=u.mm(68), C_dynamic_N=16_800.0, C0_static_N=11_600.0) +BRG_DEC_6006 = Bearing("6006-2RS (DEC output)", bore_m=u.mm(30), od_m=u.mm(55), C_dynamic_N=13_300.0, C0_static_N=8_300.0) + + +# ========================================================================== +# Counterweight system (303 SS shaft + weights). +# ========================================================================== + + +@dataclass(frozen=True) +class CounterweightSystem: + shaft_dia_m: float = u.inch(1.25) + shaft_len_m: float = u.inch(18.0) + weights_available_kg: float = 12.5 # 2x5 + 1x2.5 kg + material_key: str = "303-SS" + + @property + def material(self) -> Material: + return MATERIALS[self.material_key] + + +COUNTERWEIGHTS = CounterweightSystem() + + +# ========================================================================== +# Pier (concrete Sonotube) + steel top plate. +# ========================================================================== + + +@dataclass(frozen=True) +class Pier: + diameter_m: float = u.inch(12.0) + height_above_m: float = u.inch(36.0) + embed_depth_m: float = u.inch(36.0) + fc_Pa: float = u.psi(4000.0) + material_key: str = "concrete-4ksi" + top_plate_thk_m: float = u.inch(0.375) + top_plate_side_m: float = u.inch(12.0) + + @property + def material(self) -> Material: + return MATERIALS[self.material_key] + + +PIER = Pier() + + +# ========================================================================== +# Enclosure (roll-off roof). +# ========================================================================== + + +@dataclass(frozen=True) +class Enclosure: + kind: str = "roll-off-roof" + open_time_s: float = 45.0 # POS sim + close_time_s: float = 45.0 + motor_timeout_s: float = 60.0 + # Roof geometry is unspecified in the repo -> assumed footprint for load calcs. + roof_span_m: float = 3.0 # ASSUMED + roof_length_m: float = 3.0 # ASSUMED + roof_mass_kg: float = 180.0 # ASSUMED (framed + panelled roll-off roof) + track_incline_deg: float = 0.0 + + +ENCLOSURE = Enclosure() + + +# ========================================================================== +# Site & environment. +# ========================================================================== + + +@dataclass(frozen=True) +class Site: + latitude_deg: float = 38.9 + longitude_deg: float = -117.4 + elevation_m: float = 1800.0 + + @property + def air_density(self) -> float: + return u.isa_air_density(self.elevation_m) + + +SITE = Site() + + +@dataclass(frozen=True) +class Environment: + # --- operational interlocks (SOURCED: safety monitor / constants) --- + wind_park_ms: float = u.mph(25.0) # park threshold + wind_gust_close_ms: float = u.mph(35.0) # emergency close + temp_min_c: float = u.f_to_c(20.0) # code TEMP_MIN_F + temp_max_c: float = u.f_to_c(100.0) # code TEMP_MAX_F + humidity_max_pct: float = 85.0 + diurnal_swing_c: float = (40.0 - 0.0) * 5.0 / 9.0 # "30-40 F swing" -> ~22 C, DERIVED + # --- structural survival loads (ASSUMED — repo is silent) --- + survival_wind_ms: float = u.mph(105.0) # ASCE 7 basic wind, central NV, Risk Cat I + ground_snow_load_Pa: float = u.psf(25.0) # ASSUMED high-desert @ 6000 ft + seismic_sds_g: float = 0.50 # ASSUMED S_DS, Walker Lane vicinity + # --- aerodynamics --- + cd_cylinder: float = 1.1 # tube broadside drag coefficient + roof_uplift_gcp: float = 0.9 # ASCE net uplift coefficient (windward) + + +ENV = Environment() + + +# ========================================================================== +# Optical / focus (for the thermal budget). +# ========================================================================== + + +@dataclass(frozen=True) +class Optical: + wavelength_m: float = 0.55e-6 # visual band + astrositall_cte: float = 1.5e-7 # +/- over -60..+60 C (mirror substrate) + + +OPTICAL = Optical() + + +# ========================================================================== +# Tracking requirement (the "< 1 arcsec RMS" aspiration made a hard target). +# ========================================================================== +TRACKING_RMS_TARGET_ARCSEC = 1.0 # SOURCED aspiration (Hedrick review) +DEFLECTION_TARGET_ARCSEC = 5.0 # SOURCED target (Hedrick review) +NATURAL_FREQ_TARGET_HZ = 10.0 # SOURCED target (Hedrick review) + + +# ========================================================================== +# Provenance registry — keyed by dotted path; feeds the report's honesty table. +# ========================================================================== +PROVENANCE: dict[str, tuple[u.Provenance, str]] = { + "MN76.mass_kg": (u.Provenance.SOURCED, "NIGHTWATCH_Build_Package.md spec table"), + "MN78.mass_kg": (u.Provenance.SOURCED, "docs/INTES_MICRO_HISTORY.md ('selected')"), + "OTA.cg_from_saddle_m": (u.Provenance.DERIVED, "geometric mid-tube estimate"), + "IMAGING_TRAIN.mass_kg": (u.Provenance.SOURCED, "pos/agents/05_walton_musser.md load analysis"), + "RA_DRIVE": (u.Provenance.SOURCED, "Build_Package.md / Musser (CSF-32-100)"), + "DEC_DRIVE": (u.Provenance.SOURCED, "Build_Package.md / Musser (CSF-25-80)"), + "MOTOR.slew_rate_dps": (u.Provenance.SOURCED, "firmware/onstepx_config/Config.h"), + "ENC_MOTOR_AMT103": (u.Provenance.SOURCED, "Build_Package.md / Hedrick (8192 PPR x100)"), + "ENC_AXIS_AS5600": (u.Provenance.SOURCED, "Build_Package.md / Hedrick (12-bit on-axis)"), + "ENC_AXIS_RESA": (u.Provenance.ASSUMED, "proposed on-axis ring to meet sub-arcsec"), + "RA_HOUSING.wall_m": (u.Provenance.SOURCED, "Hedrick min 8 mm (12 mm bold review)"), + "BRG_RA_6008": (u.Provenance.SOURCED, "Build_Package.md bearing table (deep-groove C ratings)"), + "COUNTERWEIGHTS": (u.Provenance.SOURCED, "Build_Package.md counterweight table"), + "PIER": (u.Provenance.SOURCED, "Build_Package.md concrete pier spec"), + "ENCLOSURE.roof_mass_kg": (u.Provenance.ASSUMED, "roof geometry unspecified in repo"), + "SITE": (u.Provenance.SOURCED, "config.py / Config.h (central Nevada, 1800 m)"), + "SITE.air_density": (u.Provenance.DERIVED, "ISA at 1800 m"), + "ENV.wind_park_ms": (u.Provenance.SOURCED, "services/safety_monitor/monitor.py"), + "ENV.survival_wind_ms": (u.Provenance.ASSUMED, "ASCE 7 basic wind speed, central NV"), + "ENV.ground_snow_load_Pa": (u.Provenance.ASSUMED, "high-desert @ 6000 ft"), + "ENV.seismic_sds_g": (u.Provenance.ASSUMED, "Walker Lane seismicity"), + "TRACKING_RMS_TARGET_ARCSEC": (u.Provenance.SOURCED, "Hedrick '< 1 arcsec RMS'"), + "DEFLECTION_TARGET_ARCSEC": (u.Provenance.SOURCED, "Hedrick '< 5 arcsec @ 25 kg'"), + "NATURAL_FREQ_TARGET_HZ": (u.Provenance.SOURCED, "Hedrick '> 10 Hz'"), +} diff --git a/design/mechanical/calc/pier.py b/design/mechanical/calc/pier.py new file mode 100644 index 0000000..226fbed --- /dev/null +++ b/design/mechanical/calc/pier.py @@ -0,0 +1,311 @@ +""" +Pier / foundation structural proof — is the concrete pier stiff enough (pointing +tilt), high enough in frequency, and stable enough (seismic overturning + soil +embedment + concrete stress) to carry the mount? + +The pier is modelled as a solid circular concrete cantilever fixed at grade: + I = pi*d^4/64, k = 3*E*I/L^3 (L = exposed height above the top plate) + +Four independent checks, each with its own safety factor; the module reports the +governing (minimum) one: + + 1. TILT — pointing rotation of the pier top under the operational (35 mph) + OTA drag. Must be a small fraction of the 5 arcsec pointing budget. + 2. FREQ — pier-substructure first mode f = (1/2pi) sqrt(k/m_tip) with the + full tip mass (head + payload + counterweights). Must clear 10 Hz. + (The coupled head/bearing rocking mode is dynamics.py's job; here we + confirm the pier itself is not the soft element.) + 3. SEISMIC — simplified equivalent-lateral-force base shear V = S_DS*W, its + overturning moment, and stability from soil embedment + dead weight. + 4. CONCRETE — axial bearing + flexural extreme-fibre stress vs f'c and the + derived modulus of rupture (tension/cracking). + +Plus a frost-depth note: the 0.914 m embedment vs the assumed central-NV frost +line (0.3-0.6 m). + +Honest expectation: a squat 12"x36" concrete pier is very stiff and stable — all +four checks PASS — but seismic overturning relies on soil embedment: dead weight +ALONE would not resist it (SF < 1), which is the one result worth stating plainly. +""" + +from __future__ import annotations + +import math + +from . import params as P +from . import units as u +from . import wind +from .budget import BudgetResult, verdict_from_sf + +_TWO_PI = 2.0 * math.pi + +# -------------------------------------------------------------------------- +# Modelled constants the repo does not provide (tagged in .assumptions too). +# -------------------------------------------------------------------------- +# Mount head mechanical mass (RA+DEC housings, drives, saddle, top plate) as a +# lumped figure — the repo dimensions the parts but gives no assembled head mass. +MOUNT_HEAD_MASS_KG = 12.0 # ASSUMED + +# Soil for the embedded-pier stability check (repo gives no geotech data). +SOIL_UNIT_WEIGHT_N_M3 = 18000.0 # ASSUMED medium dense granular (~18 kN/m^3) +SOIL_KP = 3.0 # ASSUMED Rankine passive coeff (phi ~ 30 deg) + +# Assumed central-Nevada high-desert frost penetration depth range. +FROST_DEPTH_TYPICAL_M = 0.6 # ASSUMED conservative upper end (0.3-0.6 m) + +# Concrete modulus-of-rupture coefficient (ACI: f_r = 0.62*sqrt(f'c[MPa]) MPa). +MODULUS_OF_RUPTURE_COEFF = 0.62 # ASSUMED (code value) + + +# -------------------------------------------------------------------------- +# Pier section / stiffness helpers. +# -------------------------------------------------------------------------- +def pier_second_moment_m4(pier: P.Pier = P.PIER) -> float: + """Second moment of area of the solid circular pier (m^4): I = pi*d^4/64.""" + return math.pi * pier.diameter_m ** 4 / 64.0 + + +def pier_area_m2(pier: P.Pier = P.PIER) -> float: + return math.pi * (pier.diameter_m / 2.0) ** 2 + + +def pier_lateral_stiffness_N_per_m(pier: P.Pier = P.PIER) -> float: + """Exposed pier as a lateral cantilever: k = 3*E*I/L^3 (N/m).""" + E = pier.material.E + return 3.0 * E * pier_second_moment_m4(pier) / pier.height_above_m ** 3 + + +# -------------------------------------------------------------------------- +# Tilt (pointing) helpers. +# -------------------------------------------------------------------------- +def pier_tip_deflection_m(force_N: float, pier: P.Pier = P.PIER) -> float: + """Cantilever tip deflection under a tip force: delta = F*L^3/(3EI) (m).""" + E = pier.material.E + I = pier_second_moment_m4(pier) + return force_N * pier.height_above_m ** 3 / (3.0 * E * I) + + +def pier_tilt_arcsec(force_N: float, pier: P.Pier = P.PIER) -> float: + """Pointing rotation of the pier top under a tip force (arcsec). + + Uses the cantilever tip *slope* theta = F*L^2/(2EI) — the angle the mount + base (and hence the optical axis) actually rotates through. This is the + physically correct, conservative pointing metric (it is 1.5x the naive + delta/L estimate of the same deflection). + """ + E = pier.material.E + I = pier_second_moment_m4(pier) + theta = force_N * pier.height_above_m ** 2 / (2.0 * E * I) + return u.rad_to_arcsec(theta) + + +# -------------------------------------------------------------------------- +# Mass / frequency helpers. +# -------------------------------------------------------------------------- +def counterweight_shaft_mass_kg(cw: P.CounterweightSystem = P.COUNTERWEIGHTS) -> float: + """Solid 303-SS shaft mass derived from its geometry (kg).""" + r = cw.shaft_dia_m / 2.0 + volume = math.pi * r ** 2 * cw.shaft_len_m + return volume * cw.material.rho + + +def tip_mass_kg(ota: P.OTA, counterweighted: bool = True) -> float: + """Lumped mass riding on the pier top (kg): head + payload (+ counterweights).""" + m = MOUNT_HEAD_MASS_KG + ota.mass_kg + P.IMAGING_TRAIN.mass_kg + if counterweighted: + m += counterweight_shaft_mass_kg() + P.COUNTERWEIGHTS.weights_available_kg + return m + + +def pier_first_mode_hz(m_tip_kg: float, pier: P.Pier = P.PIER) -> float: + """Pier-substructure first mode f = (1/2pi) sqrt(k/m_tip) (Hz).""" + k = pier_lateral_stiffness_N_per_m(pier) + return math.sqrt(k / m_tip_kg) / _TWO_PI + + +# -------------------------------------------------------------------------- +# Mass bookkeeping for seismic / stress. +# -------------------------------------------------------------------------- +def pier_exposed_mass_kg(pier: P.Pier = P.PIER) -> float: + return pier_area_m2(pier) * pier.height_above_m * pier.material.rho + + +def pier_total_mass_kg(pier: P.Pier = P.PIER) -> float: + return pier_area_m2(pier) * (pier.height_above_m + pier.embed_depth_m) * pier.material.rho + + +# -------------------------------------------------------------------------- +# Seismic (simplified equivalent lateral force). +# -------------------------------------------------------------------------- +def seismic_base_shear_N(w_total_kg: float, env: P.Environment = P.ENV) -> float: + """Simplified ELF base shear V = S_DS * W (N), W = seismic weight.""" + return env.seismic_sds_g * u.weight_N(w_total_kg) + + +def seismic_overturning_Nm(ota: P.OTA, pier: P.Pier = P.PIER, + env: P.Environment = P.ENV) -> float: + """Overturning moment about grade from the ELF distribution (N.m). + + Two lumped masses: the exposed pier at its mid-height and the tip mass at the + OTA height above grade. M = S_DS * g * sum(m_i * h_i). + """ + m_tip = tip_mass_kg(ota, counterweighted=True) + m_pier = pier_exposed_mass_kg(pier) + h_tip = pier.height_above_m + wind.MOUNT_HEAD_HEIGHT_M + h_pier = pier.height_above_m / 2.0 + return env.seismic_sds_g * u.G0 * (m_pier * h_pier + m_tip * h_tip) + + +def soil_passive_resultant_N(pier: P.Pier = P.PIER) -> float: + """Rankine passive resistance developed over the embedded length (N): + Pp = 1/2 * Kp * gamma * D^2 * b (triangular, resultant at 2D/3 depth).""" + D = pier.embed_depth_m + b = pier.diameter_m + return 0.5 * SOIL_KP * SOIL_UNIT_WEIGHT_N_M3 * D ** 2 * b + + +# -------------------------------------------------------------------------- +# Concrete stress. +# -------------------------------------------------------------------------- +def modulus_of_rupture_Pa(pier: P.Pier = P.PIER) -> float: + """Concrete flexural tensile strength f_r = 0.62*sqrt(f'c[MPa]) (Pa).""" + fc_MPa = pier.fc_Pa / 1.0e6 + return MODULUS_OF_RUPTURE_COEFF * math.sqrt(fc_MPa) * 1.0e6 + + +# -------------------------------------------------------------------------- +# Budget +# -------------------------------------------------------------------------- +def evaluate(ota_key: str = "MN78") -> BudgetResult: + ota = P.OTA_CASES[ota_key] + + k = pier_lateral_stiffness_N_per_m() + I = pier_second_moment_m4() + A = pier_area_m2() + + # 1) Tilt under operational (35 mph gust) OTA drag. The drag acts at the + # elevated mount head, so it applies to the pier cantilever both a tip + # force AND a tip moment M = F*h_head; include the moment slope M*L/(EI). + f_wind = wind.drag_force_N(P.ENV.wind_gust_close_ms, ota) + E = P.PIER.material.E + L = P.PIER.height_above_m + moment_slope_arcsec = u.rad_to_arcsec(f_wind * wind.MOUNT_HEAD_HEIGHT_M * L / (E * I)) + tilt = pier_tilt_arcsec(f_wind) + moment_slope_arcsec + delta = pier_tip_deflection_m(f_wind) + sf_tilt = P.DEFLECTION_TARGET_ARCSEC / tilt + + # 2) First-mode frequency with full tip mass. + m_tip = tip_mass_kg(ota, counterweighted=True) + f_pier = pier_first_mode_hz(m_tip) + sf_freq = f_pier / P.NATURAL_FREQ_TARGET_HZ + + # 3) Seismic base shear + overturning stability. + w_seismic_kg = m_tip + pier_exposed_mass_kg() + V_shear = seismic_base_shear_N(w_seismic_kg) + M_ot = seismic_overturning_Nm(ota) + Pp = soil_passive_resultant_N() + D = P.PIER.embed_depth_m + M_resist_passive = Pp * (2.0 * D / 3.0) # about grade + W_total = u.weight_N(pier_total_mass_kg() + m_tip) + M_resist_weight = W_total * (P.PIER.diameter_m / 2.0) # dead-weight about toe + sf_seismic_ot = (M_resist_passive + M_resist_weight) / M_ot + sf_seismic_ot_deadwt = M_resist_weight / M_ot # embedment-neglected + sf_seismic_shear = Pp / V_shear + + # 4) Concrete stress: axial bearing + flexure from the governing lateral + # moment (seismic overturning governs; the OTA is shielded at survival wind). + M_flex = M_ot + sigma_axial = u.weight_N(m_tip) / A + sigma_bend = M_flex * (P.PIER.diameter_m / 2.0) / I + sigma_comp = sigma_axial + sigma_bend + sigma_tens = sigma_bend - sigma_axial + f_r = modulus_of_rupture_Pa() + sf_conc_comp = P.PIER.fc_Pa / sigma_comp + sf_conc_tens = f_r / sigma_tens + + # Frost embedment adequacy. + sf_frost = P.PIER.embed_depth_m / FROST_DEPTH_TYPICAL_M + + # Governing = worst of the four structural checks (frost reported separately). + governing_sf = min(sf_tilt, sf_freq, sf_seismic_shear, sf_seismic_ot, sf_conc_tens) + verdict = verdict_from_sf(governing_sf, pass_at=2.0, marginal_at=1.0) + + res = BudgetResult( + key="pier", + title="Pier / foundation (tilt, frequency, seismic, concrete)", + verdict=verdict, + headline=( + f"12\"x36\" concrete pier: tilt {tilt:.2f}\" (SF {sf_tilt:.0f}), " + f"f_n {f_pier:.0f} Hz (SF {sf_freq:.0f}), seismic overturning SF " + f"{sf_seismic_ot:.1f} (dead-weight-only {sf_seismic_ot_deadwt:.2f} -> embedment " + f"governs), concrete SF {min(sf_conc_comp, sf_conc_tens):.0f}. Governing SF " + f"{governing_sf:.1f} ({verdict.value})." + ), + target="Tilt << 5\"; f_n > 10 Hz; seismic stable (embed+weight); " + "concrete < f'c & modulus of rupture", + safety_factor=governing_sf, + ) + res.add("Pier I (pi d^4/64)", I, "m^4") + res.add("Pier lateral stiffness k", k, "N/m", "3EI/L^3 cantilever") + res.add("-- tilt (35 mph gust) --", 0.0, "", "") + res.add("OTA drag @ 35 mph", f_wind, "N", "from wind.py") + res.add("Pier tip deflection", delta * 1e6, "um", "F L^3/3EI") + res.add("Pier pointing tilt", tilt, "arcsec", "tip slope F L^2/2EI") + res.add("Tilt budget / actual (SF)", sf_tilt, "x", "vs 5\" pointing budget") + res.add("-- frequency --", 0.0, "", "") + res.add("Tip mass (head+payload+CW)", m_tip, "kg") + res.add("Pier first mode", f_pier, "Hz", "sqrt(k/m)/2pi") + res.add("f_n / target (SF)", sf_freq, "x", "vs 10 Hz") + res.add("-- seismic (S_DS=0.5) --", 0.0, "", "") + res.add("Seismic weight W", u.weight_N(w_seismic_kg), "N", "tip + exposed pier") + res.add("Base shear V = S_DS W", V_shear, "N") + res.add("Overturning moment", M_ot, "Nm", "about grade") + res.add("Soil passive resultant Pp", Pp, "N", "assumed granular, Kp=3") + res.add("Overturning SF (embed+weight)", sf_seismic_ot, "x") + res.add("Overturning SF (dead-weight only)", sf_seismic_ot_deadwt, "x", + "< 1 -> embedment required") + res.add("Sliding SF (Pp / V)", sf_seismic_shear, "x") + res.add("-- concrete stress --", 0.0, "", "") + res.add("Axial bearing stress", sigma_axial, "Pa") + res.add("Flexural stress (seismic)", sigma_bend, "Pa", "M c / I") + res.add("Net compression / f'c (SF)", sf_conc_comp, "x") + res.add("Net tension / modulus rupture (SF)", sf_conc_tens, "x", "cracking check") + res.add("-- frost --", 0.0, "", "") + res.add("Embedment / frost depth (SF)", sf_frost, "x", + f"0.914 m vs assumed {FROST_DEPTH_TYPICAL_M} m frost") + res.add("Governing safety factor", governing_sf, "x", verdict.value) + + res.assumptions = [ + "Pier modelled as a solid circular concrete cantilever fixed at grade " + "(I = pi*d^4/64, k = 3EI/L^3); soil-spring base fixity and pier self-mass in " + "the frequency term are neglected (slightly non-conservative on stiffness).", + "Pointing tilt uses the cantilever tip SLOPE F*L^2/2EI (the true mount-base " + "rotation), 1.5x the delta/L estimate of the tip deflection F*L^3/3EI.", + "Tilt driven by the 35 mph operational gust (roof-open max); the OTA is shielded " + "at survival wind, so wind never governs the pier structurally.", + f"Tip mass = assumed {MOUNT_HEAD_MASS_KG:.0f} kg head + payload " + f"({ota.mass_kg + P.IMAGING_TRAIN.mass_kg:.0f} kg) + counterweights " + f"({counterweight_shaft_mass_kg() + P.COUNTERWEIGHTS.weights_available_kg:.1f} kg, " + "shaft mass DERIVED from geometry); head mass ASSUMED.", + f"Seismic S_DS = {P.ENV.seismic_sds_g} g is ASSUMED (Walker Lane vicinity; repo " + "silent). Simplified ELF: V = S_DS*W, two-mass overturning distribution.", + f"Soil ASSUMED medium-dense granular: gamma = {SOIL_UNIT_WEIGHT_N_M3/1000:.0f} kN/m^3, " + f"Kp = {SOIL_KP} (phi ~ 30 deg); passive resultant over the {P.PIER.embed_depth_m:.3f} m " + "embedment provides the overturning resistance — dead weight alone would not.", + f"Concrete f'c = {P.PIER.fc_Pa/1e6:.1f} MPa (SOURCED); modulus of rupture " + f"f_r = 0.62*sqrt(f'c) = {modulus_of_rupture_Pa()/1e6:.2f} MPa (ASSUMED code value) " + "used for the tension/cracking check.", + f"Frost line ASSUMED 0.3-0.6 m for central-NV high desert; the {P.PIER.embed_depth_m:.3f} m " + "embedment (SOURCED) exceeds it.", + "Coupled head/bearing rocking (the true system first mode) is owned by dynamics.py; " + "this module only confirms the pier itself is not the soft/low-frequency element.", + ] + return res + + +if __name__ == "__main__": + r = evaluate() + print(r.verdict.symbol, r.headline) + print(r.markdown_table()) + for a in r.assumptions: + print(" -", a) diff --git a/design/mechanical/calc/power.py b/design/mechanical/calc/power.py new file mode 100644 index 0000000..487ef60 --- /dev/null +++ b/design/mechanical/calc/power.py @@ -0,0 +1,294 @@ +""" +Night-time energy budget — the repo's open power question, resolved. + +The observatory's power story has three layers, and this module sizes each and +gives the honest verdict: + +Load tally +---------- +Every night-time draw, stated with provenance: + + mount (idle/track) 12 V * IRUN * 2 axes (params: MOTOR.irun_A) + camera ~36 W (cooled CMOS + TEC, ASSUMED) + focuser ~5 W (ASSUMED) + controllers / Pi ~15 W (OnStepX + Raspberry Pi, ASSUMED) + weather / sensors ~10 W (ASSUMED) + network ~15 W (switch/router/PoE, ASSUMED) + DGX Spark ~170 W (Grace+Blackwell class, ASSUMED) + +The DGX Spark figure is the single biggest number and is ASSUMED — the repo names +the box (README "NVIDIA DGX Spark") but gives no wattage. It alone is ~60% of the +whole night load, so it dominates every downstream sizing decision. + +UPS (graceful shutdown, SOURCED spec) +------------------------------------- +A 1500 VA / 900 W UPS with ~30 min runtime rides the load only long enough to +park + close + flush (~2 min) — a ~15x energy margin on the shutdown, but it does +NOT run a full ~10 h night (it holds ~5% of one). This matches the power_manager +design (park at 50%, emergency close at 20%). + +Solar + battery (autonomy) +-------------------------- +A 400 W panel + 100 Ah LiFePO4. Battery energy depends on pack voltage, which the +repo does NOT state: 12 V -> 1.2 kWh, 48 V -> 4.8 kWh. Autonomy = usable Wh / +night load. At 12 V the pack gives only ~3.3 h — it FAILS a 10 h winter night; +at 48 V it gives ~13 h and passes. The pack voltage is therefore a decisive, +unspecified design parameter, and the 170 W DGX is why it matters. + +DGX thermal +----------- +170 W dumped into the enclosure raises the interior ~14 K above ambient at a +natural ~2 ACH — enough to wreck local seeing and invite dew — so the DGX needs +forced ventilation or, better, to live OUTSIDE the optical enclosure. + +Headline verdict is on AUTONOMY (the open question): with the specified 12 V pack +and the assumed DGX load the observatory CANNOT run a winter night off-grid. That +is the honest, important result — and it is fixed by a 48 V pack and/or by +duty-cycling the DGX instead of idling it all night. +""" + +from __future__ import annotations + +from . import params as P +from .budget import BudgetResult, verdict_from_sf + +# -------------------------------------------------------------------------- +# Load model (params has no power dataclass -> modelled here; see notes). +# -------------------------------------------------------------------------- +# Motor bus voltage. Repo firmware runs the TMC5160/NEMA17 rail at 12 V but this +# is not in params.py. ASSUMED. (V_bus * I_run over-estimates supply draw because +# the driver chops the phase current, so the mount figures are conservative.) +MOTOR_BUS_V = 12.0 # ASSUMED + +# RA + DEC = two axis motors (the two HarmonicDrive entries in params). DERIVED. +N_AXIS_MOTORS = 2 + +LOAD_CAMERA_W = 36.0 # ASSUMED: cooled CMOS with TEC at full cooling +LOAD_FOCUSER_W = 5.0 # ASSUMED +LOAD_CONTROLLERS_W = 15.0 # ASSUMED: OnStepX controller + Raspberry Pi host +LOAD_WEATHER_W = 10.0 # ASSUMED: weather station + sky/safety sensors +LOAD_NETWORK_W = 15.0 # ASSUMED: managed switch / router / PoE +DGX_SPARK_W = 170.0 # ASSUMED: NVIDIA DGX Spark (Grace+Blackwell class); +# repo names the box (README) but gives NO wattage. + +# -------------------------------------------------------------------------- +# UPS (COTS design spec; not in params.py -> see notes). Tagged SOURCED (spec). +# -------------------------------------------------------------------------- +UPS_VA = 1500.0 # SOURCED (UPS spec) +UPS_W = 900.0 # SOURCED (UPS spec) +UPS_RUNTIME_MIN_RATED = 30.0 # SOURCED (UPS spec, nominal) +# Usable battery energy of this UPS class (~2x 12 V 9 Ah AGM, ~80% usable). ASSUMED; +# cross-checked below: 150 Wh / night-load ~ 31 min, consistent with the 30-min spec. +UPS_USABLE_WH = 150.0 # ASSUMED +# Time to park + close + flush before power-down. ASSUMED (power_manager sequence). +SHUTDOWN_TIME_MIN = 2.0 # ASSUMED + +# -------------------------------------------------------------------------- +# Solar + battery (COTS design spec; not in params.py -> see notes). +# -------------------------------------------------------------------------- +PANEL_W = 400.0 # SOURCED (spec) +BATTERY_AH = 100.0 # SOURCED (spec) +DEFAULT_PACK_V = 12.0 # ASSUMED: repo silent; 48 V would be 4x the energy +BATTERY_USABLE_FRAC = 0.80 # ASSUMED: LiFePO4 depth-of-discharge for cycle life +WINTER_PEAK_SUN_HR = 4.5 # ASSUMED: central-Nevada winter peak-sun-hours +PV_DERATE = 0.75 # ASSUMED: temperature + soiling + MPPT + wiring +WINTER_NIGHT_HR = 10.0 # ASSUMED: imaging-night length to cover (astro dark +# is ~14 h in Dec, so 10 h is a generous target) +AUTONOMY_SF_PASS = 1.0 # cover exactly one night to PASS +AUTONOMY_SF_MARGINAL = 0.8 + +# -------------------------------------------------------------------------- +# DGX thermal (enclosure heat load). +# -------------------------------------------------------------------------- +ENCLOSURE_WALL_HEIGHT_M = 2.4 # ASSUMED wall height (roof footprint from params) +AIR_EXCHANGE_ACH = 2.0 # ASSUMED natural air changes per hour +CP_AIR = 1005.0 # ASSUMED specific heat of air (J/kg-K) + + +# -------------------------------------------------------------------------- +# Pure helpers (the tests call these). +# -------------------------------------------------------------------------- +def mount_power_W(slewing: bool = False) -> float: + """Mount electrical draw: V_bus * I * n_axes, I = IGOTO slewing else IRUN.""" + current = P.MOTOR.igoto_A if slewing else P.MOTOR.irun_A + return MOTOR_BUS_V * current * N_AXIS_MOTORS + + +def load_breakdown_W(slewing: bool = False) -> dict[str, float]: + """Every night-time load in watts, keyed by name.""" + return { + "mount": mount_power_W(slewing), + "camera": LOAD_CAMERA_W, + "focuser": LOAD_FOCUSER_W, + "controllers": LOAD_CONTROLLERS_W, + "weather": LOAD_WEATHER_W, + "network": LOAD_NETWORK_W, + "dgx": DGX_SPARK_W, + } + + +def night_load_W(slewing: bool = False) -> float: + """Steady night-time load (W). slewing=True swaps mount idle -> goto current.""" + return sum(load_breakdown_W(slewing).values()) + + +def ups_shutdown_margin() -> float: + """UPS usable energy / energy needed to park+close+flush = shutdown margin (x).""" + shutdown_wh = night_load_W() * (SHUTDOWN_TIME_MIN / 60.0) + return UPS_USABLE_WH / shutdown_wh + + +def ups_runtime_min(load_W: float | None = None) -> float: + """Approximate UPS runtime at a given load (min); default = night load.""" + load = night_load_W() if load_W is None else load_W + return UPS_USABLE_WH / load * 60.0 + + +def battery_usable_wh(pack_v: float = DEFAULT_PACK_V) -> float: + """Usable battery energy = Ah * V * usable-fraction (Wh).""" + return BATTERY_AH * pack_v * BATTERY_USABLE_FRAC + + +def solar_autonomy_hours(pack_v: float = DEFAULT_PACK_V) -> float: + """Battery-only autonomy at the night load (h) for a given pack voltage.""" + return battery_usable_wh(pack_v) / night_load_W() + + +def solar_daily_harvest_wh() -> float: + """Energy a 400 W panel harvests on a winter day (Wh) = W * PSH * derate.""" + return PANEL_W * WINTER_PEAK_SUN_HR * PV_DERATE + + +def enclosure_volume_m3() -> float: + """Enclosure air volume from the roof footprint x assumed wall height (m^3).""" + return P.ENCLOSURE.roof_span_m * P.ENCLOSURE.roof_length_m * ENCLOSURE_WALL_HEIGHT_M + + +def dgx_ventilation_dT(ach: float = AIR_EXCHANGE_ACH, heat_W: float = DGX_SPARK_W) -> float: + """ + Steady interior temperature rise from a heat load with a given air-change + rate: dT = Q / (rho * cp * Vdot), Vdot = ACH * volume / 3600 (K). + """ + vdot = ach * enclosure_volume_m3() / 3600.0 + return heat_W / (P.SITE.air_density * CP_AIR * vdot) + + +# -------------------------------------------------------------------------- +# Budget +# -------------------------------------------------------------------------- +def evaluate() -> BudgetResult: + loads = load_breakdown_W(slewing=False) + load = night_load_W() # steady tracking load + load_slew = night_load_W(slewing=True) # transient during a goto + dgx_frac = DGX_SPARK_W / load + + ups_margin = ups_shutdown_margin() + ups_rt = ups_runtime_min() + night_need_wh = load * WINTER_NIGHT_HR + ups_night_frac = UPS_USABLE_WH / night_need_wh + + autonomy_12 = solar_autonomy_hours(12.0) + autonomy_48 = solar_autonomy_hours(48.0) + harvest = solar_daily_harvest_wh() + harvest_frac = harvest / night_need_wh + + dT = dgx_ventilation_dT() + + # Governing verdict = AUTONOMY on the specified (12 V) pack: this is the repo's + # open question and the honest failure. UPS-shutdown is a separate PASS. + autonomy_sf = autonomy_12 / WINTER_NIGHT_HR + verdict = verdict_from_sf(autonomy_sf, pass_at=AUTONOMY_SF_PASS, marginal_at=AUTONOMY_SF_MARGINAL) + + res = BudgetResult( + key="power", + title="Night-time energy budget (UPS + solar autonomy)", + verdict=verdict, + headline=( + f"Night load ~{load:.0f} W, {dgx_frac*100:.0f}% of it the ASSUMED {DGX_SPARK_W:.0f} W DGX Spark. " + f"The 1500 VA/900 W UPS rides shutdown with {ups_margin:.0f}x margin (~{ups_rt:.0f} min) but only " + f"~{ups_night_frac*100:.0f}% of a night. Off-grid AUTONOMY {verdict.value}: the specified 400 W + " + f"100 Ah pack gives only {autonomy_12:.1f} h at 12 V (< {WINTER_NIGHT_HR:.0f} h night) — a 48 V pack " + f"gives {autonomy_48:.1f} h — and the panel harvests ~{harvest/1000:.1f} kWh/day vs ~{night_need_wh/1000:.1f} " + f"kWh needed. Drop/duty-cycle the DGX or go 48 V for true autonomy. (DGX dumps ~{dT:.0f} K into the " + f"enclosure at {AIR_EXCHANGE_ACH:.0f} ACH -> ventilate or locate it outside.)" + ), + target=f"Solar+battery sustains the ~{WINTER_NIGHT_HR:.0f} h winter night on the specified pack (SF = autonomy / night)", + safety_factor=autonomy_sf, + ) + + # Load tally. + res.add("Mount (idle/track)", loads["mount"], "W", f"{MOTOR_BUS_V:.0f} V * {P.MOTOR.irun_A} A * {N_AXIS_MOTORS} axes") + res.add("Mount (goto slew)", mount_power_W(True), "W", f"IGOTO {P.MOTOR.igoto_A} A (transient)") + res.add("Camera", loads["camera"], "W", "cooled CMOS + TEC, ASSUMED") + res.add("Focuser", loads["focuser"], "W", "ASSUMED") + res.add("Controllers / Pi", loads["controllers"], "W", "ASSUMED") + res.add("Weather / sensors", loads["weather"], "W", "ASSUMED") + res.add("Network", loads["network"], "W", "ASSUMED") + res.add("DGX Spark", loads["dgx"], "W", "ASSUMED (repo gives no wattage)") + res.add("Night load (steady)", load, "W", "sum, mount idle") + res.add("Night load (during goto)", load_slew, "W", "mount at IGOTO") + res.add("DGX share of night load", dgx_frac * 100.0, "%", "the load is DGX-dominated") + # UPS. + res.add("UPS rating", UPS_W, "W", f"{UPS_VA:.0f} VA, SOURCED spec") + res.add("UPS usable energy", UPS_USABLE_WH, "Wh", "~2x12V9Ah class, ASSUMED") + res.add("UPS runtime @ night load", ups_rt, "min", f"validates ~{UPS_RUNTIME_MIN_RATED:.0f} min spec") + res.add("Shutdown time (park+close)", SHUTDOWN_TIME_MIN, "min", "ASSUMED") + res.add("UPS shutdown margin", ups_margin, "x", "usable / shutdown energy") + res.add("UPS fraction of a night", ups_night_frac * 100.0, "%", f"of {WINTER_NIGHT_HR:.0f} h -> NOT overnight") + # Solar + battery. + res.add("Solar panel", PANEL_W, "W", "SOURCED spec") + res.add("Battery capacity", BATTERY_AH, "Ah", "LiFePO4, SOURCED spec") + res.add("Battery energy @ 12 V", battery_usable_wh(12.0), "Wh", f"{BATTERY_USABLE_FRAC*100:.0f}% usable") + res.add("Battery energy @ 48 V", battery_usable_wh(48.0), "Wh", "4x -> voltage is decisive") + res.add("Autonomy @ 12 V", autonomy_12, "h", f"< {WINTER_NIGHT_HR:.0f} h night -> FAILS") + res.add("Autonomy @ 48 V", autonomy_48, "h", f"> {WINTER_NIGHT_HR:.0f} h night -> passes") + res.add("Daily solar harvest (winter)", harvest, "Wh", f"{PANEL_W:.0f}W*{WINTER_PEAK_SUN_HR}PSH*{PV_DERATE}") + res.add("Night energy need", night_need_wh, "Wh", f"load * {WINTER_NIGHT_HR:.0f} h") + res.add("Harvest / night need", harvest_frac * 100.0, "%", "< 100% -> not fully self-sustaining") + # DGX thermal. + res.add("Enclosure volume", enclosure_volume_m3(), "m^3", f"footprint x {ENCLOSURE_WALL_HEIGHT_M} m") + res.add("DGX interior dT @ 2 ACH", dT, "K", "forced ventilation needed") + + res.assumptions = [ + f"Motor bus {MOTOR_BUS_V:.0f} V (ASSUMED; not in params) x IRUN {P.MOTOR.irun_A} A / IGOTO " + f"{P.MOTOR.igoto_A} A (params) x {N_AXIS_MOTORS} axes. V_bus*I over-estimates supply draw (the TMC " + "driver chops the phase current, and DEC holds at reduced current), so the mount figure is conservative.", + "Device wattages ASSUMED (repo gives none): camera 36 W (cooled CMOS+TEC), focuser 5 W, " + "controllers/Pi 15 W, weather/sensors 10 W, network 15 W. These sum to ~117 W of non-DGX load.", + f"DGX Spark {DGX_SPARK_W:.0f} W is ASSUMED — the README names 'NVIDIA DGX Spark' (Grace+Blackwell class) " + f"but states NO wattage. It is ~{dgx_frac*100:.0f}% of the whole night load and therefore dominates the UPS, " + "battery and thermal sizing. Every autonomy conclusion below hinges on this one assumed number.", + f"UPS 1500 VA / 900 W, ~{UPS_RUNTIME_MIN_RATED:.0f} min runtime (SOURCED spec, not in params). Usable " + f"energy {UPS_USABLE_WH:.0f} Wh (ASSUMED, ~2x12V9Ah at 80% DoD); cross-check: {UPS_USABLE_WH:.0f} Wh / " + f"{night_load_W():.0f} W ~ {ups_rt:.0f} min, consistent with the 30-min spec. Park+close+flush " + f"{SHUTDOWN_TIME_MIN:.0f} min (ASSUMED) -> {ups_margin:.0f}x shutdown margin, but the UPS holds only " + f"~{ups_night_frac*100:.0f}% of a {WINTER_NIGHT_HR:.0f} h night: it is a graceful-shutdown store, not an " + "overnight supply (matches power_manager: park at 50%, emergency close at 20%).", + f"Battery energy = {BATTERY_AH:.0f} Ah x pack V x {BATTERY_USABLE_FRAC*100:.0f}% usable. Pack voltage is " + "UNSPECIFIED in the repo and DECISIVE: 12 V -> 1.2 kWh (~3.3 h autonomy, FAILS the night); 48 V -> 4.8 kWh " + "(~13 h, passes). The headline verdict uses the lower, specified-by-default 12 V case — flag and resolve " + "the pack voltage.", + f"Autonomy = usable Wh / night load; target = one {WINTER_NIGHT_HR:.0f} h winter imaging night (ASSUMED; " + "astronomical dark is ~14 h in December, so 10 h is a generous target). SF = autonomy / night; pass at " + f"{AUTONOMY_SF_PASS:.1f}. At 12 V SF = {autonomy_sf:.2f} -> FAIL.", + f"Winter solar harvest = {PANEL_W:.0f} W x {WINTER_PEAK_SUN_HR} PSH x {PV_DERATE} derate = {harvest:.0f} Wh/day " + f"(all ASSUMED), vs {night_need_wh:.0f} Wh/night -> harvest is only ~{harvest_frac*100:.0f}% of a night's " + "energy, so even a bigger battery drains over successive nights without grid. The 400 W array cannot " + "sustain the DGX-dominated load off-grid; duty-cycling the DGX (run inference on demand, not idle all " + "night) is the highest-leverage fix.", + f"DGX thermal: {DGX_SPARK_W:.0f} W into a {enclosure_volume_m3():.0f} m^3 enclosure (footprint x " + f"{ENCLOSURE_WALL_HEIGHT_M} m, ASSUMED) at {AIR_EXCHANGE_ACH:.0f} natural ACH (ASSUMED) gives " + f"dT = Q/(rho*cp*Vdot) ~ {dT:.0f} K rise — enough to spoil local seeing and drive dew. Force ~10 ACH of " + "ventilation, or locate the DGX OUTSIDE the optical enclosure. cp_air 1005 J/kg-K, rho " + f"{P.SITE.air_density:.3f} kg/m^3 at 1800 m (DERIVED).", + "CONCLUSION (open question resolved): graceful shutdown is comfortably covered; full off-grid autonomy " + "on the specified 12 V / 400 W system is NOT — the assumed 170 W DGX Spark is the reason. Specify the " + "pack at 48 V and/or duty-cycle the DGX to make a winter night off-grid feasible.", + ] + return res + + +if __name__ == "__main__": + r = evaluate() + print(r.verdict.symbol, r.headline) + print(r.markdown_table()) diff --git a/design/mechanical/calc/report.py b/design/mechanical/calc/report.py new file mode 100644 index 0000000..104705e --- /dev/null +++ b/design/mechanical/calc/report.py @@ -0,0 +1,212 @@ +""" +Report generator — assembles design/mechanical/MECHANICAL_DESIGN.md from the +calculator so every number in the report is *generated*, never hand-typed. + +Run: python3 -m design.mechanical.calc.report (writes the .md) +A test (test_report.py) regenerates and byte-compares, so the report can never +drift from the proofs. +""" + +from __future__ import annotations + +import pathlib + +from . import ( + balance, + bearings, + dynamics, + enclosure, + encoder, + pier, + power, + stiffness, + thermal, + torque, + wind, +) +from . import params as P +from .budget import Verdict + +# Ordered proof modules (each exposes evaluate()). +PROOFS = [ + ("1", torque), ("2", stiffness), ("3", dynamics), ("4", encoder), + ("5", balance), ("6", bearings), ("7", wind), ("8", pier), + ("9", thermal), ("10", enclosure), ("11", power), +] + +_HERE = pathlib.Path(__file__).resolve().parents[1] # design/mechanical/ +OUT = _HERE / "MECHANICAL_DESIGN.md" +TRADE_SECTION = _HERE / "tradestudy" / "SECTION.md" +CAD_SECTION = _HERE / "cad" / "SECTION.md" + + +FRONT_MATTER = """# NIGHTWATCH — Observatory Mechanical Design: Rigorous Proof-Out + +> **Generated artifact.** Every number below is computed by the `design/mechanical/calc/` +> package and regenerated by `python3 -m design.mechanical.calc.report`. The test +> `test_report.py` fails if this file drifts from the calculator, so the figures cannot +> silently disagree the way the repo's docs currently do. +> +> **Honesty labels.** Every input is tagged **S** (sourced — stated in a repo file), +> **D** (derived — computed here), or **A** (assumed — the repo is silent; an engineering +> assumption is stated). Nothing here is measured field data; the observatory has never been built. + +## 1. Why this exists + +The repo already documents a mechanical design — `NIGHTWATCH_Build_Package.md` (spec table, costed +BOM, concrete-pier spec) and a `pos/` panel of expert personas (C. Walton Musser on the harmonic +drives, Richard Hedrick on frame stiffness). But it is **assertion, not proof**: the headline +targets are stated, never calculated, and the single most load-bearing fact — which telescope — +contradicts itself. This report turns the prose into computed, self-consistent, margin-carrying +engineering, runs the selection permutations as a real weighted trade study, makes and defends the +bold choices the numbers justify, and fills the CAD and environmental-load voids. + +## 2. Resolved contradictions + +The repo carries numbers that disagree across files. This design pins each to one value in +`params.py` and records the conflict so it is fixed, not buried. + +| # | Contradiction (as found) | Resolution here | +|---|---|---| +| 1 | **OTA identity**: MN76 178 mm f/6 1068 mm **~9 kg** (Build Package) vs MN78 180 mm **f/8** 1440 mm **~14 kg** (`INTES_MICRO_HISTORY.md`, "selected") | Both carried as load cases (`MN76`, `MN78`); the torque + stiffness proofs and the trade study decide (see §Trade Study). | +| 2 | **Encoder PPR**: AMT103 "8192 PPR" everywhere vs "2048 PPR" (`HARDWARE_SETUP.md`) | Pinned to 8192 PPR motor-side; 2048 flagged as a doc error. | +| 3 | **On-axis resolution**: AS5600 "12-bit / 4096" vs `EncoderConfig.resolution=8192` | AS5600 is 12-bit → 4096 counts/rev = ~316 arcsec/count; the 8192 config default is unrelated and misleading. | +| 4 | **Horizon/altitude limit**: 10° (safety monitor) / 15° (`constants.py`) / 20° (scheduler) | Not a mechanical limit — advisory; the mount has no hard slew-altitude cutoff (a noted gap). | +| 5 | **Operating temperature**: −20…40 °C (YAML) vs 20…100 °F (code) | Reconciled to °C internally (−6.7…38 °C from the code Fahrenheit values). | +| 6 | **Site coordinates**: 38.9 / −117.4 (software) vs 39.0 / −117.0 (firmware) | Pinned to 38.9 °N / −117.4 °W, 1800 m for all load derivations. | +| 7 | **Bearings** called "angular contact" but specified as 6008/6006, which are **deep-groove** | Flagged; the bearings + stiffness proofs recommend 7008/7006 angular-contact pairs for moment stiffness. | + +## 3. The three governing voids (repo is silent — filled here, labelled ASSUMED) + +A 6000 ft, seismically-active, remote Nevada site is governed by three structural loads the repo +never states. The software encodes only *operational* interlocks (park at 25 mph, close at 35 mph +gust), which are **not** structural survival ratings. This design makes them first-class: + +| Void | Repo status | Assumed design load (A) | Governs | +|---|---|---|---| +| **Survival wind** | absent (only 25/35 mph operational) | ASCE 7 basic wind ~105 mph 3-sec gust, Risk Cat I | roof anchors, pier overturning | +| **Snow load** | absent entirely | ~25 psf ground snow, high-desert @ 6000 ft | closed-roof structure, roof slope | +| **Seismic** | absent entirely | S_DS ≈ 0.5 g (Walker Lane vicinity) | pier base shear, anchorage | + +Two more the repo flags as open and this design closes: **DGX Spark power/heat** (assumed ~170 W → +energy + enclosure-thermal budget) and the **power/autonomy budget** (solar+battery autonomy hours). +""" + + +def results_at_a_glance(results) -> str: + rows = ["## 4. Results at a glance", "", + "| # | Proof | Verdict | Governing number |", "|---|---|---|---|"] + for num, r in results: + rows.append(f"| {num} | {r.title} | {r.verdict.symbol} {r.verdict.value} | {r.headline} |") + passes = sum(1 for _, r in results if r.verdict is Verdict.PASS) + fails = sum(1 for _, r in results if r.verdict is Verdict.FAIL) + marg = sum(1 for _, r in results if r.verdict is Verdict.MARGINAL) + rows += ["", + f"**{passes} PASS · {marg} MARGINAL · {fails} FAIL.** The FAILs are the value: each is a " + "real gap in the repo's design that assertion had hidden — the bearing-limited stiffness, " + "the sub-arcsec-incapable encoder chain, the missing survival-wind anchors, passive focus " + "over the diurnal swing, and off-grid autonomy. Every FAIL carries a computed remedy below."] + return "\n".join(rows) + + +def proof_section(num: str, r) -> str: + parts = [f"### {num}. {r.title} — {r.verdict.symbol} {r.verdict.value}", "", + f"**{r.headline}**", ""] + if r.target: + parts.append(f"*Target:* {r.target} ") + if r.safety_factor is not None: + parts.append(f"*Governing safety factor:* {r.safety_factor:.2f}×") + parts += ["", r.markdown_table(), ""] + if r.assumptions: + parts.append("_Assumptions:_") + for a in r.assumptions: + parts.append(f"- {a}") + parts.append("") + return "\n".join(parts) + + +def provenance_table() -> str: + rows = ["## 6. Provenance ledger", "", + "Where each key input comes from — the honesty table the repo lacks.", "", + "| Parameter | Provenance | Source / basis |", "|---|:--:|---|"] + for key, (prov, src) in P.PROVENANCE.items(): + rows.append(f"| `{key}` | **{prov.marker}** | {src} |") + rows += ["", "**S** = sourced (repo file) · **D** = derived (computed) · **A** = assumed (repo silent)."] + return "\n".join(rows) + + +SYNTHESIS = """## 9. Selected configuration, cost delta, and roadmap + +The trade study's selected permutation, with the cost delta against the repo's **$8,305** BOM +(DGX excluded) and a build sequencing that ships a working v1 before the sub-arcsec v2. + +| Subsystem | Selected | Why | Cost delta | +|---|---|---|---| +| OTA | **MN78 f/8** (science override) | f/8 scale + 0.134 obstruction for Mars/lucky imaging; MN76 is the mechanical hedge | baseline | +| Topology | **Counterweight-FREE GEM** (bold) | torque proof SF 2.5/2.6; deletes 15.4 kg + 29% RA inertia | −$110 (no CW set) | +| Drive | NEMA17 + 27:1 + harmonic | PASS; planetary PE mooted by on-axis encoder | baseline | +| Encoder | **Hybrid → on-axis absolute** (mandatory) | baseline DISQUALIFIED (5.02″ RMS); on-axis ring 0.54″ | +$1,500–5,000 | +| Bearings | **Angular-contact 7008/7006** (fix) | deep-groove 6008/6006 are 98% of the stiffness FAIL | +$80 | +| Pier | Concrete Sonotube | PASS SF 6.8; not the constraint | baseline | +| Enclosure | **Roll-off + active thermal** (bold) | closes thermal FAIL + DGX heat | +$300 | +| Anchors | **4× 2 klbf hold-downs** (fix) | survival-wind uplift SF 0.19 → 3.9 | +$120 | +| Power | Grid + UPS (v1); **48 V pack** for autonomy | 12 V pack gives 3.3 h vs 10 h night | +$400 (v2) | + +**Roadmap.** +- **v1 (buildable now):** MN78 counterweight-free GEM on the concrete pier, roll-off roof with the + mandatory hold-down anchors and snow interlock, angular-contact bearings, grid + UPS power, + temperature-compensated focus. Meets every structural and thermal requirement; tracks to ~5″ + (motor-encoder + PE), which is fine for the wide-field/lucky-imaging use with plate-solve recentering. +- **v2 (sub-arcsecond):** add the on-axis absolute ring (closes the 1″ tracking target), active-thermal + enclosure, and — if precision is weighted above cost — the torque-motor direct drive. Off-grid + autonomy via the 48 V battery + duty-cycled DGX. + +## 10. Risk register + +| Risk | Severity | Mitigation (computed) | +|---|---|---| +| Frame stiffness FAIL (43″) | High | Angular-contact 7008/7006 back-to-back on max spans; verify with the stiffness proof once bearing stiffness is vendor-confirmed | +| Sub-arcsec unmet by baseline encoder | High | On-axis absolute ring (proof: 0.54″); v1 relies on plate-solve recentering | +| Survival-wind roof uplift (SF 0.19) | High | 4× 2 klbf hold-down anchors (SF 3.9) + snow interlock | +| Passive focus drift over diurnal swing | Medium | Temperature-compensated focuser (repo already has −2.5 steps/°C) | +| Off-grid autonomy shortfall | Medium | 48 V pack (13.4 h) or DGX duty-cycle; v1 stays on grid + UPS | +| Bearing-stiffness assumption (250/180 N/µm) | Medium | Confirm vendor radial stiffness; it scales the deflection linearly | +| DGX watts/heat unspecified (assumed 170 W) | Low | Ventilate or locate the DGX outside the OTA enclosure | + +--- + +_Generated by `design/mechanical/calc/report.py` from the proof modules. Regenerate with +`python3 -m design.mechanical.calc.report`; `test_report.py` guards against drift._ +""" + + +def build_markdown() -> str: + results = [(num, mod.evaluate()) for num, mod in PROOFS] + blocks = [FRONT_MATTER.rstrip(), "", results_at_a_glance(results), "", + "## 5. The proofs", ""] + for num, r in results: + blocks.append(proof_section(num, r)) + blocks.append(provenance_table()) + blocks.append("") + # Trade study (section 7) and CAD (section 8): read the versioned narratives verbatim, + # re-numbering their top heading into this document's outline. + trade = TRADE_SECTION.read_text().strip() + trade = trade.replace("## Trade Study (Phase B)", "## 7. Trade Study (Phase B)", 1) + cad = CAD_SECTION.read_text().strip() + cad = cad.replace("## Mechanical CAD (Phase D)", "## 8. Mechanical CAD (Phase D)", 1) + blocks.append(trade) + blocks.append("") + blocks.append(cad) + blocks.append("") + blocks.append(SYNTHESIS.strip()) + text = "\n".join(b for b in blocks if b is not None) + return text.rstrip() + "\n" + + +def main() -> None: + OUT.write_text(build_markdown()) + print(f"wrote {OUT} ({OUT.stat().st_size} bytes)") + + +if __name__ == "__main__": + main() diff --git a/design/mechanical/calc/stiffness.py b/design/mechanical/calc/stiffness.py new file mode 100644 index 0000000..8ea85a3 --- /dev/null +++ b/design/mechanical/calc/stiffness.py @@ -0,0 +1,232 @@ +""" +Static pointing-deflection proof — does the mount head hold the optical axis to +within P.DEFLECTION_TARGET_ARCSEC (5 arcsec) under gravity? + +Worst case: the OTA is horizontal (pointing at the horizon), so gravity acts +perpendicular to the optical axis and the payload weight hangs off the DEC axis +at its full CG lever. That gravity moment bends the load path, and every micron +of compliance at the tiny bearing spans (63-76 mm) is amplified into arcseconds +of pointing error. + +Load path modelled as a series compliance chain (each term adds pointing error): + + payload weight W --(lever L_cg)--> moment M at the DEC axis + |-- DEC housing box-beam bends theta = M*L / (E*I) + |-- DEC bearing pair deflects radially F = M/span, theta = 2*delta/span + |-- RA housing box-beam bends (overhung) theta = M*L / (E*I) + |-- RA bearing pair deflects radially F = M/span, theta = 2*delta/span + +Sum the four angular terms, convert rad -> arcsec, compare to the 5 arcsec +target for BOTH the 8 mm baseline housings and the 12 mm bold-review variants. + +Honest expectation: the deep-groove bearings the repo specifies, at the 63-76 mm +spans set by the housing depths, are the governing compliance and the beam-wall +upgrade (8 -> 12 mm) barely moves the number. This is where the repo's asserted +"< 5 arcsec" is finally computed rather than assumed. +""" + +from __future__ import annotations + +from . import params as P +from . import units as u +from .budget import BudgetResult, Verdict, verdict_from_sf + +# -------------------------------------------------------------------------- +# Modelled constants the repo does not provide (tagged in .assumptions too). +# -------------------------------------------------------------------------- +# Radial stiffness of the output bearings. The repo lists only load ratings +# (C, C0), not stiffness, so these are ASSUMED from typical small deep-groove +# ball-bearing radial stiffness: 6008 ~ 250 N/um, 6006 ~ 180 N/um. +BEARING_RADIAL_STIFFNESS_N_PER_M: dict[str, float] = { + "6008": 250.0e6, # 250 N/um (RA output) -- ASSUMED + "6006": 180.0e6, # 180 N/um (DEC output) -- ASSUMED +} +K_RA_BEARING = BEARING_RADIAL_STIFFNESS_N_PER_M["6008"] +K_DEC_BEARING = BEARING_RADIAL_STIFFNESS_N_PER_M["6006"] + +# The DEC rotation axis sits below the tube/CG plane by the dovetail + saddle +# stack. The repo does not dimension the saddle, so this radial offset is +# ASSUMED at 50 mm and added to the along-tube CG lever to form a worst-case +# combined gravity lever L_cg. DERIVED/ASSUMED geometry. +SADDLE_LEVER_M = u.mm(50.0) + + +# -------------------------------------------------------------------------- +# Pure helpers (the tests call these directly). +# -------------------------------------------------------------------------- +def box_section_I(housing: P.Housing) -> float: + """Second moment of area of a rectangular box section (m^4). + + I = (b*h^3 - (b-2t)*(h-2t)^3)/12 for outer b x h and wall t. + """ + b, h, t = housing.outer_x_m, housing.outer_y_m, housing.wall_m + return (b * h ** 3 - (b - 2.0 * t) * (h - 2.0 * t) ** 3) / 12.0 + + +def payload_mass(ota: P.OTA) -> float: + """OTA tube + lumped rear imaging train (kg).""" + return ota.mass_kg + P.IMAGING_TRAIN.mass_kg + + +def cg_lever_m(ota: P.OTA) -> float: + """Worst-case gravity lever from the DEC axis to the payload CG (m). + + Along-tube CG offset (OTA.cg_from_saddle_m) + assumed saddle radial offset. + """ + return ota.cg_from_saddle_m + SADDLE_LEVER_M + + +def payload_moment_Nm(ota: P.OTA) -> float: + """Gravity moment about the DEC axis with the tube horizontal (N.m).""" + return u.weight_N(payload_mass(ota)) * cg_lever_m(ota) + + +def beam_angular_deflection_rad(M: float, housing: P.Housing) -> float: + """Tip rotation of a box cantilever of length = bearing span under end + moment M: theta = M*L/(E*I) (rad).""" + E = housing.material.E + I = box_section_I(housing) + return M * housing.depth_m / (E * I) + + +def bearing_angular_deflection_rad(M: float, span: float, k: float) -> float: + """Angular tilt from a bearing pair reacting moment M as a force couple. + + F = M/span in each bearing, radial deflection delta = F/k, and the housing + tilts by (delta + delta)/span = 2*delta/span (rad). + """ + F = M / span + delta = F / k + return 2.0 * delta / span + + +def deflection_breakdown(ota: P.OTA, ra_housing: P.Housing, + dec_housing: P.Housing) -> dict[str, float]: + """Every angular term (rad) plus the arcsec total for one configuration.""" + M = payload_moment_Nm(ota) + dec_beam = beam_angular_deflection_rad(M, dec_housing) + ra_beam = beam_angular_deflection_rad(M, ra_housing) + dec_brg = bearing_angular_deflection_rad(M, dec_housing.depth_m, K_DEC_BEARING) + ra_brg = bearing_angular_deflection_rad(M, ra_housing.depth_m, K_RA_BEARING) + total = dec_beam + ra_beam + dec_brg + ra_brg + return { + "moment_Nm": M, + "dec_beam_rad": dec_beam, + "ra_beam_rad": ra_beam, + "dec_bearing_rad": dec_brg, + "ra_bearing_rad": ra_brg, + "beam_rad": dec_beam + ra_beam, + "bearing_rad": dec_brg + ra_brg, + "total_rad": total, + "total_arcsec": u.rad_to_arcsec(total), + } + + +def total_deflection_arcsec(ota: P.OTA, ra_housing: P.Housing, + dec_housing: P.Housing) -> float: + """Summed static pointing deflection (arcsec) for the given configuration.""" + return deflection_breakdown(ota, ra_housing, dec_housing)["total_arcsec"] + + +# -------------------------------------------------------------------------- +# Translational helpers reused by dynamics.py (same beam/bearing model). +# -------------------------------------------------------------------------- +def beam_translational_stiffness_N_per_m(housing: P.Housing) -> float: + """Cantilever tip translational stiffness k = 3*E*I/L^3 (N/m).""" + E = housing.material.E + I = box_section_I(housing) + return 3.0 * E * I / housing.depth_m ** 3 + + +def bearing_pair_stiffness_N_per_m(k_single: float) -> float: + """Two bearings share a lateral load in parallel -> 2x radial stiffness.""" + return 2.0 * k_single + + +# -------------------------------------------------------------------------- +# Budget +# -------------------------------------------------------------------------- +def _sf_and_verdict(total_arcsec: float) -> tuple[float, Verdict]: + sf = P.DEFLECTION_TARGET_ARCSEC / total_arcsec + # PASS only with a real 1.5x pointing margin; MARGINAL if merely inside 5". + return sf, verdict_from_sf(sf, pass_at=1.5, marginal_at=1.0) + + +def evaluate(ota_key: str = "MN78") -> BudgetResult: + ota = P.OTA_CASES[ota_key] + base = deflection_breakdown(ota, P.RA_HOUSING, P.DEC_HOUSING) + bold = deflection_breakdown(ota, P.RA_HOUSING_12MM, P.DEC_HOUSING_12MM) + + sf_base, v_base = _sf_and_verdict(base["total_arcsec"]) + sf_bold, v_bold = _sf_and_verdict(bold["total_arcsec"]) + + # The bold 12 mm build is the design recommendation -> governing verdict, + # but the headline reports both so a FAIL cannot hide behind the upgrade. + verdict = v_bold + tgt = P.DEFLECTION_TARGET_ARCSEC + + res = BudgetResult( + key="stiffness", + title="Static pointing deflection (OTA horizontal, worst case)", + verdict=verdict, + headline=( + f"{ota.name}: 8 mm baseline {base['total_arcsec']:.1f}\" " + f"({v_base.value}), 12 mm bold {bold['total_arcsec']:.1f}\" " + f"({v_bold.value}) vs {tgt:.0f}\" target -- bearing compliance at the " + f"{dec_span_mm(P.DEC_HOUSING):.1f}/{ra_span_mm(P.RA_HOUSING):.1f} mm " + f"spans governs." + ), + target=f"Summed gravity deflection < {tgt:.0f} arcsec (Hedrick), SF>=1.5 to PASS", + safety_factor=sf_bold, + ) + res.add("Payload mass (OTA + train)", payload_mass(ota), "kg") + res.add("Gravity lever L_cg (cg + saddle)", cg_lever_m(ota), "m", + "along-tube CG + assumed 50 mm saddle offset") + res.add("Gravity moment at DEC axis", base["moment_Nm"], "Nm", "W x L_cg, tube horizontal") + res.add("-- 8 mm baseline housings --", 0.0, "", "") + res.add("DEC housing beam deflection", u.rad_to_arcsec(base["dec_beam_rad"]), "arcsec") + res.add("RA housing beam deflection", u.rad_to_arcsec(base["ra_beam_rad"]), "arcsec") + res.add("DEC bearing-pair tilt", u.rad_to_arcsec(base["dec_bearing_rad"]), "arcsec", + "6006 @ 180 N/um, 63.5 mm span") + res.add("RA bearing-pair tilt", u.rad_to_arcsec(base["ra_bearing_rad"]), "arcsec", + "6008 @ 250 N/um, 76.2 mm span") + res.add("8 mm TOTAL deflection", base["total_arcsec"], "arcsec") + res.add("8 mm target / actual (SF)", sf_base, "x", v_base.value) + res.add("-- 12 mm bold housings --", 0.0, "", "") + res.add("12 mm beam deflection (DEC+RA)", u.rad_to_arcsec(bold["beam_rad"]), "arcsec") + res.add("12 mm bearing tilt (DEC+RA)", u.rad_to_arcsec(bold["bearing_rad"]), "arcsec", + "unchanged -- wall does not stiffen bearings") + res.add("12 mm TOTAL deflection", bold["total_arcsec"], "arcsec") + res.add("12 mm target / actual (SF)", sf_bold, "x", v_bold.value) + res.add("Bearing share of total (8 mm)", 100.0 * base["bearing_rad"] / base["total_rad"], "%") + + res.assumptions = [ + "Worst case: OTA horizontal, gravity perpendicular to the optical axis (SOURCED as the design case).", + f"L_cg = OTA.cg_from_saddle ({ota.cg_from_saddle_m*1000:.0f} mm) + saddle offset " + f"{SADDLE_LEVER_M*1000:.0f} mm (saddle undimensioned in repo, ASSUMED).", + "Bearing radial stiffness 6008~250 N/um, 6006~180 N/um (repo lists only load " + "ratings, not stiffness) -- ASSUMED.", + "Bearing span = housing depth (bearings at the box faces): DEC 63.5 mm, RA 76.2 mm.", + "The full payload moment is applied through BOTH the DEC and RA load paths " + "(overhung bending on the RA bearings persists even when torque-balanced) -- " + "a bounding assumption.", + "Deep-groove bearings modelled; the repo labels them 'angular contact' (flagged " + "in params) -- a preloaded angular-contact pair would raise stiffness materially.", + ] + return res + + +def ra_span_mm(h: P.Housing) -> float: + return h.depth_m * 1000.0 + + +def dec_span_mm(h: P.Housing) -> float: + return h.depth_m * 1000.0 + + +if __name__ == "__main__": + r = evaluate() + print(r.verdict.symbol, r.headline) + print(r.markdown_table()) + for a in r.assumptions: + print(" -", a) diff --git a/design/mechanical/calc/thermal.py b/design/mechanical/calc/thermal.py new file mode 100644 index 0000000..8c93b18 --- /dev/null +++ b/design/mechanical/calc/thermal.py @@ -0,0 +1,245 @@ +""" +Thermal focus-stability proof — over the site's diurnal temperature swing, does +the focus hold PASSIVELY, or is an active temperature-compensated focuser +required? And can a small dew heater keep the corrector above dewpoint? + +Physics +------- +Depth of focus (the axial in-focus window, one-sided): + + DoF = +/- 2 * lambda * N**2 (lambda = wavelength, N = f-ratio) + +The f/8 MN78 has a LARGER DoF than the f/6 MN76 (DoF scales as N^2), which is a +genuine point in the slower system's favour. + +Tube-expansion defocus. The 6061-T6 aluminium tube is the metering structure +that separates the primary mirror cell from the focuser. When it grows by +dL = CTE_al * L_tube * dT the focuser (and the sensor bolted to it) walks away +from the fixed image plane by ~dL to first order (a Newtonian/Mak-Newt has no +strong secondary magnification, so the primary-to-focuser spacing maps ~1:1 to +defocus). We use the *tube length* rather than the focal length as the aluminium +metering length: it is the physical aluminium that expands, and for the folded +Mak-Newts the tube (700 / 1400 mm) is the honest — and less alarming — figure +(focal length would be 1068 / 1440 mm). + + defocus_per_K = CTE_al * L_tube + +Astrositall mirror. The mirror substrate CTE (~1.5e-7 /K) is ~157x smaller than +aluminium, so the mirror's own focal-shift term (f * CTE_mirror * dT) is +negligible: the ENTIRE thermal focus error is the aluminium tube. That is the +whole reason the primary is low-expansion glass. + +Dew. Heater power to hold the corrector a few K above dewpoint, P = h*A*dT with +h a lumped convective+radiative coefficient and A the corrector area from the +aperture; compared against a 2 W all-sky ring reference. + +Conclusion (headline): over the ~22 K diurnal swing the aluminium tube walks the +focus by ~9-10 depths of focus (only ~2 K to leave best focus), so PASSIVE focus +is NOT enough — a temperature-compensated focuser is REQUIRED. The repo's focuser +already carries a temperature coefficient (-2.5 steps/C), so the fix exists; this +proof shows it is not optional. +""" + +from __future__ import annotations + +import math + +from . import params as P +from .budget import BudgetResult, verdict_from_sf + +# -------------------------------------------------------------------------- +# Modelled constants (the repo's params.py is silent on these). +# -------------------------------------------------------------------------- +# The aluminium tube is the metering structure; its CTE comes from params. +TUBE_MATERIAL_KEY = "6061-T6" + +# Lumped heat-transfer coefficient corrector-glass -> still night air (natural +# convection + radiation to sky). 5-15 W/m^2K is the usual still-air band; 10 is +# a central ASSUMED value. ASSUMED. +HEAT_TRANSFER_COEFF_WM2K = 10.0 + +# Hold the corrector this many K above the dewpoint to suppress dew. ASSUMED. +DEW_MARGIN_K = 5.0 + +# Reference dew-heater ring power to compare against. A small all-sky / corrector +# dew ring is ~2 W. Stated engineering reference figure. ASSUMED (reference). +ALL_SKY_RING_W = 2.0 + +# Repo focuser default temperature coefficient (steps/C). NOT in params.py — it +# lives in the focuser service (tests/unit/test_focuser_service.py default +# -2.5; docs/API + telescope_tools note ~2.5 steps/C). SOURCED (repo, elsewhere). +FOCUSER_TEMP_COEFF_STEPS_PER_C = -2.5 + +# The POS retreat sim models MN78 defocus as 2.0 microns/C +# (pos/POS_RETREAT_SIMULATION.md TEMP_COEFFICIENT). First principles below give +# ~33 um/K for a BARE aluminium tube -> the POS figure implicitly assumes a +# low-CTE (carbon / compensated) tube or is optimistic. SOURCED (reference). +POS_TEMP_COEFF_UM_PER_C = 2.0 + + +# -------------------------------------------------------------------------- +# Pure helpers (the tests call these). +# -------------------------------------------------------------------------- +def depth_of_focus_um(ota: P.OTA) -> float: + """ + One-sided depth of focus (+/-), micrometres: DoF = 2 * lambda * N^2. + The full in-focus window is twice this value. Grows as N^2, so the slower + f/8 tube tolerates more axial error than the f/6. + """ + return 2.0 * P.OPTICAL.wavelength_m * ota.f_ratio ** 2 * 1.0e6 + + +def defocus_per_kelvin_um(ota: P.OTA) -> float: + """ + Focus shift per kelvin from the aluminium tube, micrometres/K: + defocus_per_K = CTE_al * L_tube (1:1 tube-growth -> defocus, first order). + """ + cte_al = P.MATERIALS[TUBE_MATERIAL_KEY].cte + return cte_al * ota.tube_length_m * 1.0e6 + + +def tube_defocus_um(ota: P.OTA, delta_t_c: float) -> float: + """Total aluminium-tube defocus (micrometres) over a temperature change.""" + return defocus_per_kelvin_um(ota) * delta_t_c + + +def kelvin_to_defocus(ota: P.OTA) -> float: + """ + Kelvin of tube temperature change to walk from best focus to the EDGE of the + depth of focus, i.e. "how many K before out of focus": + K_edge = DoF / defocus_per_K + A small number here means passive focus is fragile. + """ + return depth_of_focus_um(ota) / defocus_per_kelvin_um(ota) + + +def mirror_defocus_per_kelvin_um(ota: P.OTA) -> float: + """ + Astrositall primary's own focus shift per kelvin, micrometres/K: + ~ f * CTE_mirror (the radius, hence focal length, scales with CTE_mirror). + Uses the system focal length as a conservative (upper-bound) proxy for the + primary's focal length. Should be ~2 orders of magnitude below the tube term. + """ + return P.OPTICAL.astrositall_cte * ota.focal_length_m * 1.0e6 + + +def corrector_area_m2(ota: P.OTA) -> float: + """Corrector clear area from the aperture (m^2). The meniscus glass is a few + percent larger than the clear aperture, so this slightly under-estimates.""" + return math.pi * (ota.aperture_m / 2.0) ** 2 + + +def dew_heater_power_w( + ota: P.OTA, + h: float = HEAT_TRANSFER_COEFF_WM2K, + dT: float = DEW_MARGIN_K, +) -> float: + """Steady heater power to hold the corrector dT above ambient: P = h*A*dT.""" + return h * corrector_area_m2(ota) * dT + + +# -------------------------------------------------------------------------- +# Budget +# -------------------------------------------------------------------------- +def evaluate(ota_key: str = "MN78") -> BudgetResult: + ota = P.OTA_CASES[ota_key] + swing = P.ENV.diurnal_swing_c + + # Depth of focus, both cases (the f/8 vs f/6 comparison is a design point). + dof_mn76 = depth_of_focus_um(P.MN76) + dof_mn78 = depth_of_focus_um(P.MN78) + dof = depth_of_focus_um(ota) + + per_k = defocus_per_kelvin_um(ota) + k_edge = kelvin_to_defocus(ota) # K to leave best focus + defocus_swing = tube_defocus_um(ota, swing) # um over the full swing + n_dof = defocus_swing / dof # how many DoF the tube walks + + mir_per_k = mirror_defocus_per_kelvin_um(ota) + mir_swing = mir_per_k * swing + tube_over_mirror = per_k / mir_per_k + + heater_w = dew_heater_power_w(ota) + + # Passive-focus safety factor = allowable dT (to edge of DoF) / actual swing. + # < 1 means the swing blows through the depth of focus -> passive fails. + passive_sf = k_edge / swing + verdict = verdict_from_sf(passive_sf, pass_at=2.0, marginal_at=1.0) + + res = BudgetResult( + key="thermal", + title="Thermal focus stability over the diurnal swing", + verdict=verdict, + headline=( + f"Passive focus is NOT enough: over the {swing:.0f} K diurnal swing the 6061-T6 tube " + f"walks focus ~{n_dof:.0f}x the f/{ota.f_ratio:.0f} depth of focus (only {k_edge:.1f} K " + f"to leave best focus) -> a temperature-compensated focuser is REQUIRED (the repo focuser " + f"has a {FOCUSER_TEMP_COEFF_STEPS_PER_C:.1f} steps/C coefficient). The Astrositall mirror " + f"adds only ~{mir_swing:.0f} um ({100.0/tube_over_mirror:.1f}% of the tube term); a " + f"~{heater_w:.1f} W corrector heater beats the {ALL_SKY_RING_W:.0f} W all-sky ring." + ), + target=f"Passive focus stays within +/- DoF over the {swing:.0f} K swing (SF = K_to_edge / swing)", + safety_factor=passive_sf, + ) + + # Depth of focus (f/8 wins). + res.add("Depth of focus, MN76 f/6 (+/-)", dof_mn76, "um", "2*lambda*N^2") + res.add("Depth of focus, MN78 f/8 (+/-)", dof_mn78, "um", "larger -> f/8 more forgiving") + res.add("DoF ratio f8/f6", dof_mn78 / dof_mn76, "x", "~(8/6)^2 = 1.78 (scales as N^2)") + # Tube-driven defocus for the selected OTA. + res.add(f"Tube length ({ota_key})", ota.tube_length_m, "m", "aluminium metering length (choice)") + res.add("Aluminium CTE", P.MATERIALS[TUBE_MATERIAL_KEY].cte, "1/K", "6061-T6 (params)") + res.add("Tube defocus per K", per_k, "um/K", "CTE_al * L_tube") + res.add("K to leave best focus", k_edge, "K", "DoF / (defocus per K)") + res.add("Diurnal swing", swing, "K", "P.ENV.diurnal_swing_c") + res.add("Tube defocus over swing", defocus_swing, "um", f"= {n_dof:.1f} x DoF") + res.add("Passive-focus SF", passive_sf, "x", "K_to_edge / swing (<1 => fails)") + # Mirror residual (the big win). + res.add("Astrositall CTE", P.OPTICAL.astrositall_cte, "1/K", "~157x below aluminium") + res.add("Mirror defocus per K", mir_per_k, "um/K", "f * CTE_mirror") + res.add("Mirror defocus over swing", mir_swing, "um", "negligible vs tube") + res.add("Tube / mirror defocus ratio", tube_over_mirror, "x", "aluminium dominates") + # Cross-check against the POS coefficient. + res.add("POS modelled coeff (MN78)", POS_TEMP_COEFF_UM_PER_C, "um/K", + "vs first-principles bare-Al tube -> optimistic") + # Dew heater. + res.add("Corrector area (from aperture)", corrector_area_m2(ota), "m^2", "pi*(D/2)^2") + res.add(f"Dew heater P (hold +{DEW_MARGIN_K:.0f} K)", heater_w, "W", + f"h={HEAT_TRANSFER_COEFF_WM2K:.0f} W/m^2K") + res.add("All-sky ring reference", ALL_SKY_RING_W, "W", "heater fits inside this") + + res.assumptions = [ + "Depth of focus DoF = 2*lambda*N^2 (one-sided, +/-), lambda = P.OPTICAL.wavelength_m " + "(0.55 um); the full in-focus window is twice this. DoF scales as N^2 so f/8 > f/6.", + "Tube-expansion defocus uses the TUBE LENGTH (P.OTA.tube_length_m) as the aluminium " + "metering length, not the focal length: it is the physical aluminium between the primary " + "cell and the focuser, and for these folded Mak-Newts (700/1400 mm tube vs 1068/1440 mm " + "focal) it is the more physical and less alarming choice. DERIVED.", + "Tube growth maps ~1:1 to defocus (no strong secondary magnification in a Mak-Newt); a " + "catadioptric focus-amplification factor would make passive focus WORSE, so 1:1 is " + "non-conservative-favourable and the FAIL verdict is robust. ASSUMED.", + "Mirror term ~ f * CTE_mirror with CTE_mirror = P.OPTICAL.astrositall_cte (1.5e-7/K); " + "system focal length used as an upper-bound proxy for the primary's focal length. DERIVED.", + f"Dew heater P = h*A*dT with h = {HEAT_TRANSFER_COEFF_WM2K:.0f} W/m^2K (still-air " + f"convection+radiation, ASSUMED), dT = {DEW_MARGIN_K:.0f} K above dewpoint (ASSUMED), " + "A = corrector area from the clear aperture (meniscus is a few % larger -> mild under-estimate).", + f"All-sky dew ring reference = {ALL_SKY_RING_W:.0f} W (stated reference figure, ASSUMED).", + f"Focuser temperature coefficient {FOCUSER_TEMP_COEFF_STEPS_PER_C:.1f} steps/C is SOURCED from " + "the repo focuser service (tests/unit/test_focuser_service.py default; docs/telescope_tools " + "note ~2.5 steps/C) but is NOT in params.py; a focuser step size would be needed to convert " + "steps -> microns and confirm the compensation resolves the tube term. NOT in params (see notes).", + f"FINDING: first-principles bare-6061-T6 defocus is ~{per_k:.0f} um/K, whereas the POS retreat " + f"sim models MN78 at {POS_TEMP_COEFF_UM_PER_C:.0f} um/K " + "(pos/POS_RETREAT_SIMULATION.md TEMP_COEFFICIENT) -> the POS figure implies a low-CTE " + "(carbon-fibre / compensated) tube or is ~16x optimistic for bare aluminium. SOURCED cross-check.", + "CONCLUSION: passive focus FAILS the diurnal swing by ~10x; a temperature-compensated focuser " + "(which the repo has) is REQUIRED, not optional. The Astrositall primary and a <2 W corrector " + "heater are genuine wins that make the residual tractable once the tube term is compensated.", + ] + return res + + +if __name__ == "__main__": + r = evaluate() + print(r.verdict.symbol, r.headline) + print(r.markdown_table()) diff --git a/design/mechanical/calc/torque.py b/design/mechanical/calc/torque.py new file mode 100644 index 0000000..07e2ea1 --- /dev/null +++ b/design/mechanical/calc/torque.py @@ -0,0 +1,127 @@ +""" +Axis torque budget — does each harmonic drive have the holding + dynamic torque +to carry the payload, and can the ~9 kg of counterweights be deleted? + +Worst-case axis torque = gravity imbalance + wind + goto inertia + drive/bearing +friction, compared against the CSF drives' rated (continuous) and peak torques. + +Headline decision: a strain-wave drive resists back-drive, so a *counterweight- +free* GEM only needs holding torque >= payload_weight x CG-offset. We compute +that and show the safety factor vs the 127 Nm (RA) / 70 Nm (DEC) ratings — the +same principle the ZWO AM5 / RST-135 exploit to run without counterweights. +""" + +from __future__ import annotations + +import math + +from . import params as P +from . import units as u +from .budget import BudgetResult, verdict_from_sf + +# Modelled geometry (DERIVED estimates; the pier/stiffness modules refine these). +RA_CG_OFFSET_M = 0.20 # horizontal CG offset of payload from the polar axis +DEC_RESIDUAL_LEVER_M = 0.02 # along-tube imbalance left after balancing +DEC_WIND_LEVER_FRAC = 0.34 # center-of-pressure lever about DEC as frac of tube length +BALANCE_ERROR_FRAC = 0.02 # residual imbalance when counterweighted & balanced +FRICTION_FRACTION = 0.05 # harmonic + bearing seal drag, frac of rated torque + + +def _drag_force(V_ms: float, ota: P.OTA, env: P.Environment, site: P.Site) -> float: + """Broadside wind drag on the tube (N). wind.py owns the structural version.""" + area = ota.tube_od_m * ota.tube_length_m + return 0.5 * site.air_density * V_ms ** 2 * env.cd_cylinder * area + + +def _payload_mass(ota: P.OTA) -> float: + return ota.mass_kg + P.IMAGING_TRAIN.mass_kg + + +def ra_required_torque(ota: P.OTA, counterweight_free: bool) -> dict[str, float]: + W = u.weight_N(_payload_mass(ota)) + if counterweight_free: + t_gravity = W * RA_CG_OFFSET_M + else: + t_gravity = W * RA_CG_OFFSET_M * BALANCE_ERROR_FRAC + f_wind = _drag_force(P.ENV.wind_gust_close_ms, ota, P.ENV, P.SITE) + t_wind = f_wind * RA_CG_OFFSET_M + # Inertia about polar axis (payload ~ point mass at offset + tube self-inertia). + i_axis = _payload_mass(ota) * RA_CG_OFFSET_M ** 2 + \ + (1.0 / 12.0) * ota.mass_kg * ota.tube_length_m ** 2 + alpha = math.radians(P.MOTOR.accel_dps2) + t_inertia = i_axis * alpha + t_friction = FRICTION_FRACTION * P.RA_DRIVE.rated_torque_Nm + total = t_gravity + t_wind + t_inertia + t_friction + return {"gravity": t_gravity, "wind": t_wind, "inertia": t_inertia, + "friction": t_friction, "total": total} + + +def dec_required_torque(ota: P.OTA) -> dict[str, float]: + W = u.weight_N(_payload_mass(ota)) + t_gravity = W * DEC_RESIDUAL_LEVER_M + f_wind = _drag_force(P.ENV.wind_gust_close_ms, ota, P.ENV, P.SITE) + t_wind = f_wind * DEC_WIND_LEVER_FRAC * ota.tube_length_m + i_axis = (1.0 / 12.0) * ota.mass_kg * ota.tube_length_m ** 2 + \ + P.IMAGING_TRAIN.mass_kg * (0.5 * ota.tube_length_m) ** 2 + alpha = math.radians(P.MOTOR.accel_dps2) + t_inertia = i_axis * alpha + t_friction = FRICTION_FRACTION * P.DEC_DRIVE.rated_torque_Nm + total = t_gravity + t_wind + t_inertia + t_friction + return {"gravity": t_gravity, "wind": t_wind, "inertia": t_inertia, + "friction": t_friction, "total": total} + + +def evaluate(ota_key: str = "MN78", counterweight_free: bool = True) -> BudgetResult: + ota = P.OTA_CASES[ota_key] + ra = ra_required_torque(ota, counterweight_free) + dec = dec_required_torque(ota) + + ra_sf_rated = P.RA_DRIVE.rated_torque_Nm / ra["total"] + ra_sf_peak = P.RA_DRIVE.peak_torque_Nm / ra["total"] + dec_sf_rated = P.DEC_DRIVE.rated_torque_Nm / dec["total"] + dec_sf_peak = P.DEC_DRIVE.peak_torque_Nm / dec["total"] + + governing_sf = min(ra_sf_rated, dec_sf_rated) + verdict = verdict_from_sf(governing_sf, pass_at=2.0, marginal_at=1.5) + + mode = "counterweight-FREE" if counterweight_free else "counterweighted" + res = BudgetResult( + key="torque", + title="Axis torque budget", + verdict=verdict, + headline=( + f"{ota.name}, {mode}: RA needs {ra['total']:.1f} Nm vs " + f"{P.RA_DRIVE.rated_torque_Nm:.0f} Nm rated (SF {ra_sf_rated:.1f}); " + f"DEC needs {dec['total']:.1f} Nm vs {P.DEC_DRIVE.rated_torque_Nm:.0f} Nm rated " + f"(SF {dec_sf_rated:.1f})." + ), + target="Required axis torque < rated (SF>=2 continuous, peak covers goto)", + safety_factor=governing_sf, + ) + res.add("Payload mass (OTA + train)", _payload_mass(ota), "kg") + res.add("RA gravity imbalance torque", ra["gravity"], "Nm", "payload weight x CG offset") + res.add("RA wind torque @ 35 mph gust", ra["wind"], "Nm") + res.add("RA goto-inertia torque", ra["inertia"], "Nm") + res.add("RA friction torque", ra["friction"], "Nm") + res.add("RA total required", ra["total"], "Nm") + res.add("RA rated / required (SF)", ra_sf_rated, "x") + res.add("RA peak / required (SF)", ra_sf_peak, "x") + res.add("DEC total required", dec["total"], "Nm") + res.add("DEC rated / required (SF)", dec_sf_rated, "x") + res.add("DEC peak / required (SF)", dec_sf_peak, "x") + res.assumptions = [ + f"RA CG offset from polar axis = {RA_CG_OFFSET_M*1000:.0f} mm (GEM geometry estimate, DERIVED)", + f"Balance residual (counterweighted) = {BALANCE_ERROR_FRAC*100:.0f}% (ASSUMED)", + f"Drive+bearing friction = {FRICTION_FRACTION*100:.0f}% of rated torque (ASSUMED)", + f"DEC wind center-of-pressure at {DEC_WIND_LEVER_FRAC*100:.0f}% of tube length " + f"from the DEC pivot (ASSUMED, conservative; governs the DEC total).", + "Wind torque uses the 35 mph emergency-close gust (max wind while open), SOURCED.", + f"Air density {P.SITE.air_density:.3f} kg/m^3 at 1800 m (DERIVED), 17% below sea level.", + ] + return res + + +if __name__ == "__main__": + r = evaluate() + print(r.verdict.symbol, r.headline) + print(r.markdown_table()) diff --git a/design/mechanical/calc/units.py b/design/mechanical/calc/units.py new file mode 100644 index 0000000..6651353 --- /dev/null +++ b/design/mechanical/calc/units.py @@ -0,0 +1,170 @@ +""" +Units, physical constants, and provenance tagging for the NIGHTWATCH +mechanical-design calculator. + +Everything in this package computes in **SI base units** (metre, kilogram, +second, newton, pascal, kelvin, radian). Conversion helpers below turn the +imperial / astronomy figures quoted in the repo's docs into SI so there is a +single internal system and the numbers can no longer disagree file-to-file. + +Provenance +---------- +Every input parameter in ``params.py`` is tagged with a ``Provenance`` so the +report can honestly separate what the repo actually specifies from what we +derived or had to assume (the repo is silent on survival wind, snow, seismic, +and DGX power — those are ASSUMED and labelled as such). +""" + +from __future__ import annotations + +import enum +import math + +# -------------------------------------------------------------------------- +# Provenance +# -------------------------------------------------------------------------- + + +class Provenance(enum.Enum): + """Where a parameter's value comes from.""" + + SOURCED = "sourced" # stated in the repo (a doc, config, or firmware file) + DERIVED = "derived" # computed from sourced values inside this package + ASSUMED = "assumed" # repo is silent; a stated engineering assumption + + @property + def marker(self) -> str: + return {"sourced": "S", "derived": "D", "assumed": "A"}[self.value] + + +# -------------------------------------------------------------------------- +# Length +# -------------------------------------------------------------------------- +IN_TO_M = 0.0254 +FT_TO_M = 0.3048 +MM_TO_M = 1.0e-3 + + +def inch(x: float) -> float: + return x * IN_TO_M + + +def foot(x: float) -> float: + return x * FT_TO_M + + +def mm(x: float) -> float: + return x * MM_TO_M + + +# -------------------------------------------------------------------------- +# Mass / force +# -------------------------------------------------------------------------- +LB_TO_KG = 0.45359237 +G0 = 9.80665 # m/s^2, standard gravity + + +def lb(x: float) -> float: + return x * LB_TO_KG + + +def weight_N(mass_kg: float) -> float: + """Gravitational force (newtons) of a mass at standard gravity.""" + return mass_kg * G0 + + +# -------------------------------------------------------------------------- +# Speed +# -------------------------------------------------------------------------- +MPH_TO_MS = 0.44704 + + +def mph(x: float) -> float: + return x * MPH_TO_MS + + +# -------------------------------------------------------------------------- +# Pressure +# -------------------------------------------------------------------------- +PSI_TO_PA = 6894.757 +PSF_TO_PA = 47.880259 # pounds per square foot -> pascal + + +def psi(x: float) -> float: + return x * PSI_TO_PA + + +def psf(x: float) -> float: + return x * PSF_TO_PA + + +# -------------------------------------------------------------------------- +# Angle (astronomy) +# -------------------------------------------------------------------------- +ARCSEC_PER_REV = 360.0 * 3600.0 # 1_296_000 +ARCSEC_PER_RAD = 180.0 * 3600.0 / math.pi # ~206264.806 + + +def rad_to_arcsec(theta_rad: float) -> float: + return theta_rad * ARCSEC_PER_RAD + + +def arcsec_to_rad(theta_arcsec: float) -> float: + return theta_arcsec / ARCSEC_PER_RAD + + +def deg_to_rad(deg: float) -> float: + return math.radians(deg) + + +# -------------------------------------------------------------------------- +# Temperature +# -------------------------------------------------------------------------- +def f_to_c(f: float) -> float: + return (f - 32.0) * 5.0 / 9.0 + + +def c_to_k(c: float) -> float: + return c + 273.15 + + +# -------------------------------------------------------------------------- +# Atmosphere (International Standard Atmosphere, troposphere) +# -------------------------------------------------------------------------- +def isa_air_density(altitude_m: float, sea_level_density: float = 1.225) -> float: + """ + ISA air density (kg/m^3) at a geopotential altitude. Central Nevada sits at + ~1800 m, where air is ~17% thinner than sea level — this materially reduces + wind drag and is normally ignored in amateur mount design. DERIVED. + """ + # rho = rho0 * (1 - L*h/T0)^(g*M/(R*L) - 1), troposphere lapse form. + return sea_level_density * (1.0 - 2.25577e-5 * altitude_m) ** 4.25588 + + +__all__ = [ + "ARCSEC_PER_RAD", + "ARCSEC_PER_REV", + "FT_TO_M", + "G0", + "IN_TO_M", + "LB_TO_KG", + "MM_TO_M", + "MPH_TO_MS", + "PSF_TO_PA", + "PSI_TO_PA", + "Provenance", + "arcsec_to_rad", + "c_to_k", + "deg_to_rad", + "f_to_c", + "foot", + "inch", + "isa_air_density", + "lb", + "mm", + "mph", + "psf", + "psi", + "rad_to_arcsec", + "weight_N", +] diff --git a/design/mechanical/calc/wind.py b/design/mechanical/calc/wind.py new file mode 100644 index 0000000..3da6b56 --- /dev/null +++ b/design/mechanical/calc/wind.py @@ -0,0 +1,240 @@ +""" +Wind structural-load proof — THE authoritative wind calculation for the whole +package (torque.py deliberately defers its own drag helper to this module). + +Two regimes, two very different jobs: + + 1. OPERATIONAL (roof OPEN, telescope exposed). The safety monitor parks at + 25 mph and emergency-closes at 35 mph, so 35 mph is the most wind the OTA + ever sees while pointing. The broadside drag on the tube and the moment it + puts into the pier top are what torque.py and pier.py consume for the + tracking / pointing budgets. These loads are small. + + 2. SURVIVAL (roof CLOSED, telescope shielded). The governing structural case + is the roll-off enclosure at the ASCE-7 basic wind speed (105 mph, ASSUMED + — the repo is silent). A near-flat roof develops large net UPLIFT, and the + tall box develops large lateral drag. This is where the repo has a genuine + void: no roof hold-down or enclosure anchorage is specified, and the roof's + own weight resists only a fraction of the uplift. + +Dynamic pressure throughout: q = 1/2 rho V^2 with rho = P.SITE.air_density (ISA +at 1800 m, ~1.03 kg/m^3 — 16% below sea level, which honestly *reduces* every +load below the usual sea-level assumption). + +Honest expectation encoded below: SURVIVAL WIND GOVERNS, and the roll-off roof +FAILS to resist its own uplift by dead weight (SF ~0.19) — hold-down anchors are +mandatory and unspecified. That FAIL is the finding worth surfacing, exactly the +kind of structural void params.py flags survival wind as. +""" + +from __future__ import annotations + +from . import params as P +from . import units as u +from .budget import BudgetResult, verdict_from_sf + +# -------------------------------------------------------------------------- +# Modelled geometry / coefficients the repo does not provide. +# (All tagged again in result.assumptions.) +# -------------------------------------------------------------------------- +# OTA optical-axis height above the pier top (mount head build height). The repo +# dimensions neither the head nor the saddle stack, so this is ASSUMED. Used only +# to turn the operational drag force into an overturning moment. +MOUNT_HEAD_HEIGHT_M = 0.50 # ASSUMED + +# Roll-off enclosure box (footprint = roof span x length from params, height and +# cladding mass are unspecified -> ASSUMED). +WALL_HEIGHT_M = 2.4 # ASSUMED enclosure wall height +WALL_AREAL_MASS_KG_M2 = 12.0 # ASSUMED light steel panel + framing +WALL_FORCE_COEFF = 1.2 # ASSUMED net force coefficient, low-rise box (windward+leeward) + +# Hold-down anchorage used to demonstrate a *remediation*, since the repo omits +# it entirely. A common 1/2" wedge anchor into 4 ksi concrete carries ~2000 lbf +# allowable; four (one per roof corner) is the minimum credible scheme. +ROOF_ANCHOR_ALLOW_N = u.weight_N(u.lb(2000.0)) # ASSUMED ~2000 lbf allowable tension per anchor +ROOF_ANCHOR_COUNT = 4 # ASSUMED (one per roof corner) + + +# -------------------------------------------------------------------------- +# Pure helpers (the tests call these directly). +# -------------------------------------------------------------------------- +def dynamic_pressure_Pa(V_ms: float, site: P.Site = P.SITE) -> float: + """Stagnation dynamic pressure q = 1/2 rho V^2 (Pa) at the site air density.""" + return 0.5 * site.air_density * V_ms ** 2 + + +def tube_area_m2(ota: P.OTA) -> float: + """Broadside projected area of the closed tube (m^2) = OD x length.""" + return ota.tube_od_m * ota.tube_length_m + + +def drag_force_N(V_ms: float, ota: P.OTA, + env: P.Environment = P.ENV, site: P.Site = P.SITE) -> float: + """Broadside wind drag on the OTA tube (N): F = q * Cd * A. + + This is the single authoritative drag used across the package (torque.py's + private copy uses the identical formula and parameters). + """ + return dynamic_pressure_Pa(V_ms, site) * env.cd_cylinder * tube_area_m2(ota) + + +def ota_wind_moment_Nm(V_ms: float, ota: P.OTA, + pier: P.Pier = P.PIER) -> float: + """Overturning moment at the pier BASE from OTA drag (N.m). + + Lever = exposed pier height + mount-head height (OTA optical axis above the + pier top). The moment resisted at the pier *top* alone is F*mount_head. + """ + lever = pier.height_above_m + MOUNT_HEAD_HEIGHT_M + return drag_force_N(V_ms, ota) * lever + + +def roof_area_m2(enc: P.Enclosure = P.ENCLOSURE) -> float: + return enc.roof_span_m * enc.roof_length_m + + +def roof_uplift_N(env: P.Environment = P.ENV, enc: P.Enclosure = P.ENCLOSURE, + site: P.Site = P.SITE) -> float: + """Gross survival wind uplift on the (near-flat) roll-off roof (N): + U = q_survival * GCp_uplift * A_roof.""" + q = dynamic_pressure_Pa(env.survival_wind_ms, site) + return q * env.roof_uplift_gcp * roof_area_m2(enc) + + +def roof_weight_N(enc: P.Enclosure = P.ENCLOSURE) -> float: + return u.weight_N(enc.roof_mass_kg) + + +def roof_net_uplift_N() -> float: + """Uplift the anchors must resist after crediting roof dead weight (N).""" + return roof_uplift_N() - roof_weight_N() + + +def enclosure_wall_area_m2(enc: P.Enclosure = P.ENCLOSURE) -> float: + """Windward wall projected area (m^2) = roof length x assumed wall height.""" + return enc.roof_length_m * WALL_HEIGHT_M + + +def enclosure_lateral_force_N(env: P.Environment = P.ENV, enc: P.Enclosure = P.ENCLOSURE, + site: P.Site = P.SITE) -> float: + """Survival lateral drag on the enclosure box (N): q_survival * Cf * A_wall.""" + q = dynamic_pressure_Pa(env.survival_wind_ms, site) + return q * WALL_FORCE_COEFF * enclosure_wall_area_m2(enc) + + +def enclosure_weight_N(enc: P.Enclosure = P.ENCLOSURE) -> float: + """Roof + wall-cladding dead weight of the enclosure (N).""" + perimeter = 2.0 * (enc.roof_span_m + enc.roof_length_m) + wall_mass = perimeter * WALL_HEIGHT_M * WALL_AREAL_MASS_KG_M2 + return u.weight_N(enc.roof_mass_kg + wall_mass) + + +def enclosure_overturning_Nm(enc: P.Enclosure = P.ENCLOSURE) -> float: + """Survival overturning moment of the box about its leeward base edge (N.m). + Lateral resultant applied at wall mid-height.""" + return enclosure_lateral_force_N() * (WALL_HEIGHT_M / 2.0) + + +# -------------------------------------------------------------------------- +# Budget +# -------------------------------------------------------------------------- +def evaluate(ota_key: str = "MN78") -> BudgetResult: + ota = P.OTA_CASES[ota_key] + + # --- operational (roof open): OTA drag at park and gust --- + f_park = drag_force_N(P.ENV.wind_park_ms, ota) + f_gust = drag_force_N(P.ENV.wind_gust_close_ms, ota) + m_gust_base = ota_wind_moment_Nm(P.ENV.wind_gust_close_ms, ota) + m_gust_top = f_gust * MOUNT_HEAD_HEIGHT_M + + # --- survival (roof closed): enclosure loads --- + q_surv = dynamic_pressure_Pa(P.ENV.survival_wind_ms) + uplift = roof_uplift_N() + roof_wt = roof_weight_N() + net_uplift = roof_net_uplift_N() + lateral = enclosure_lateral_force_N() + overturn = enclosure_overturning_Nm() + + # Roof hold-down: dead weight alone vs uplift, then the assumed anchor scheme. + sf_selfweight = roof_wt / uplift + anchor_capacity = ROOF_ANCHOR_COUNT * ROOF_ANCHOR_ALLOW_N + sf_anchored = anchor_capacity / uplift + + # Enclosure overturning: dead-weight-only vs with windward anchors. + enc_stab_weight = enclosure_weight_N() * (min(P.ENCLOSURE.roof_span_m, + P.ENCLOSURE.roof_length_m) / 2.0) + sf_overturn_deadwt = enc_stab_weight / overturn + + # Governing verdict = the roof self-weight hold-down (the starkest survival + # finding). As-specified (no anchors in repo) the roof CANNOT hold itself + # down -> FAIL. The anchor line shows the remediation. + governing_sf = sf_selfweight + verdict = verdict_from_sf(governing_sf, pass_at=1.5, marginal_at=1.0) + + survival_governs = uplift > f_gust and overturn > m_gust_base + + res = BudgetResult( + key="wind", + title="Wind structural loads (operational drag + survival uplift)", + verdict=verdict, + headline=( + f"Survival wind (105 mph) GOVERNS: {uplift/1e3:.1f} kN roof uplift vs " + f"{roof_wt/1e3:.1f} kN roof self-weight (SF {sf_selfweight:.2f}) -- " + f"hold-down anchors are MANDATORY and unspecified in the repo " + f"(4x 2 klbf anchors -> SF {sf_anchored:.1f}). Operational gust drag " + f"on {ota.name} is only {f_gust:.0f} N." + ), + target="Survival roof uplift resisted by hold-down (self-weight insufficient); " + "operational drag feeds torque/pier budgets", + safety_factor=governing_sf, + ) + # Operational block. + res.add("Air density @ 1800 m", P.SITE.air_density, "kg/m^3", "ISA, 16% below sea level") + res.add("q @ 25 mph park", dynamic_pressure_Pa(P.ENV.wind_park_ms), "Pa") + res.add("q @ 35 mph gust", dynamic_pressure_Pa(P.ENV.wind_gust_close_ms), "Pa") + res.add("OTA drag @ 25 mph (park)", f_park, "N") + res.add("OTA drag @ 35 mph (gust)", f_gust, "N", "max wind while open") + res.add("Wind moment at pier TOP @ gust", m_gust_top, "Nm", "F x mount-head height") + res.add("Wind moment at pier BASE @ gust", m_gust_base, "Nm", "F x (pier + head height)") + # Survival block. + res.add("-- survival, roof closed --", 0.0, "", "") + res.add("q @ 105 mph survival", q_surv, "Pa") + res.add("Roof gross uplift", uplift, "N", "q x GCp x A_roof") + res.add("Roof self-weight", roof_wt, "N") + res.add("Roof NET uplift (anchor demand)", net_uplift, "N", "uplift - self-weight") + res.add("Roof self-weight / uplift (SF)", sf_selfweight, "x", verdict.value) + res.add("Assumed anchor capacity (4x)", anchor_capacity, "N") + res.add("Anchored hold-down (SF)", sf_anchored, "x", "remediation") + res.add("Enclosure lateral drag", lateral, "N", "q x Cf x A_wall") + res.add("Enclosure overturning moment", overturn, "Nm", "about leeward base edge") + res.add("Overturning, dead-weight only (SF)", sf_overturn_deadwt, "x", + "anchors required" if sf_overturn_deadwt < 1.0 else "") + res.add("Survival governs vs operational", 1.0 if survival_governs else 0.0, "bool") + + res.assumptions = [ + f"Dynamic pressure uses site air density {P.SITE.air_density:.3f} kg/m^3 " + f"(ISA @ 1800 m, DERIVED) — lower than sea level, so loads are honest not inflated.", + "Operational max wind = 35 mph emergency-close gust (SOURCED safety monitor); the " + "OTA is only exposed with the roof open, so it never sees survival wind.", + f"Survival basic wind {P.ENV.survival_wind_ms/u.MPH_TO_MS:.0f} mph is ASSUMED " + "(ASCE 7 central-NV Risk Cat I; repo is silent — a governing void).", + f"Roof footprint {roof_area_m2():.0f} m^2 and mass {P.ENCLOSURE.roof_mass_kg:.0f} kg " + "are ASSUMED (params flags roof geometry as unspecified).", + f"Net uplift coefficient GCp = {P.ENV.roof_uplift_gcp} (SOURCED param); wall force " + f"coefficient Cf = {WALL_FORCE_COEFF} on an assumed {WALL_HEIGHT_M} m wall (ASSUMED).", + f"Mount-head height {MOUNT_HEAD_HEIGHT_M*1000:.0f} mm (OTA axis above pier top) is " + "ASSUMED; sets the operational overturning lever.", + "Repo specifies NO roof hold-down or enclosure anchorage; the anchor scheme " + "(4x 2 klbf, ASSUMED) is shown only as remediation, not as an existing spec.", + "The pier is assumed structurally isolated from the enclosure (standard observatory " + "practice), so roof uplift loads the enclosure foundation, not the pier.", + ] + return res + + +if __name__ == "__main__": + r = evaluate() + print(r.verdict.symbol, r.headline) + print(r.markdown_table()) + for a in r.assumptions: + print(" -", a) diff --git a/design/mechanical/tests/__init__.py b/design/mechanical/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/design/mechanical/tests/test_balance.py b/design/mechanical/tests/test_balance.py new file mode 100644 index 0000000..56d304c --- /dev/null +++ b/design/mechanical/tests/test_balance.py @@ -0,0 +1,75 @@ +"""Mass-balance proof: the mount must balance the payload with the available +12.5 kg inside the 18" shaft, AND the counterweight-free option must be shown +viable (inherited from the torque proof), deleting real steel and RA inertia.""" + +import math + +from design.mechanical.calc import balance, torque +from design.mechanical.calc import params as P +from design.mechanical.calc.budget import Verdict + + +def test_required_position_solves_the_moment_equation(): + """r_cw must satisfy m_cw * r_cw = m_payload * r_payload exactly.""" + r_cw = balance.required_counterweight_position_m(P.MN78) + m_pay = P.MN78.mass_kg + P.IMAGING_TRAIN.mass_kg + lhs = P.COUNTERWEIGHTS.weights_available_kg * r_cw + rhs = m_pay * torque.RA_CG_OFFSET_M + assert math.isclose(lhs, rhs, rel_tol=1e-12) + + +def test_counterweight_fits_within_the_shaft(): + """The 12.5 kg must balance the MN78 within the 18" (0.457 m) shaft.""" + r_cw = balance.required_counterweight_position_m(P.MN78) + assert r_cw <= P.COUNTERWEIGHTS.shaft_len_m + # Real seating margin, not right at the tip. + assert P.COUNTERWEIGHTS.shaft_len_m / r_cw >= 1.3 + + +def test_heavier_ota_needs_more_shaft(): + """MN78 (heavier) must need a longer r_cw than MN76 — model sanity.""" + r_light = balance.required_counterweight_position_m(P.MN76) + r_heavy = balance.required_counterweight_position_m(P.MN78) + assert r_heavy > r_light + + +def test_deleted_mass_is_weights_plus_shaft(): + """Counterweight-free deletes both the weights and the 303-SS shaft.""" + shaft = balance.shaft_mass_kg() + deleted = balance.deleted_mass_kg() + assert math.isclose(deleted, P.COUNTERWEIGHTS.weights_available_kg + shaft, rel_tol=1e-12) + # Shaft is real, non-trivial steel (rho*A*L ~ 2.9 kg). + assert 2.0 < shaft < 4.0 + # So total deleted noticeably exceeds the weights alone. + assert deleted > P.COUNTERWEIGHTS.weights_available_kg + 2.0 + + +def test_counterweight_free_cuts_ra_inertia_meaningfully(): + """Removing the counterweight drops a meaningful fraction of RA inertia.""" + del_I = balance.deleted_ra_inertia_kgm2(P.MN78) + i_pay = balance._ra_payload_inertia_kgm2(P.MN78) + assert del_I > 0.0 + reduction = del_I / (i_pay + del_I) + # Deleting ~15 kg of steel at ~0.29 m should cut ~20-40% of RA inertia. + assert 0.20 <= reduction <= 0.40 + + +def test_evaluate_passes_and_ties_to_torque_proof(): + """PASS requires: balances within the shaft AND cw-free viable per torque.""" + r = balance.evaluate("MN78") + assert r.verdict is Verdict.PASS + # safety_factor is the shaft fit factor (>1 => fits). + assert r.safety_factor is not None and r.safety_factor > 1.0 + # The counterweight-free verdict must be inherited, not silently asserted. + tq = torque.evaluate("MN78", counterweight_free=True) + assert tq.verdict in (Verdict.PASS, Verdict.MARGINAL) + assert any("torque" in a.lower() for a in r.assumptions) + assert any("Required shaft position r_cw" in ln.label for ln in r.lines) + + +def test_dec_balance_is_qualitative_and_small(): + """DEC needs no counterweight: residual moment after dovetail adjust is small.""" + r = balance.evaluate("MN78") + residual = next(ln for ln in r.lines if "DEC residual moment" in ln.label) + # Residual imbalance (18 kg x 20 mm ~ 3.5 Nm) is tiny vs the 70 Nm DEC drive. + assert residual.value < 0.1 * P.DEC_DRIVE.rated_torque_Nm diff --git a/design/mechanical/tests/test_bearings.py b/design/mechanical/tests/test_bearings.py new file mode 100644 index 0000000..842cc88 --- /dev/null +++ b/design/mechanical/tests/test_bearings.py @@ -0,0 +1,72 @@ +"""Bearing proof: rolling fatigue (L10) is astronomically long because the mount +barely turns, static safety is comfortable, and the governing finding is that the +'angular contact' 60xx are actually DEEP-GROOVE — angular-contact pairs are needed +for moment stiffness. FAIL is not expected here; the load capacity is a non-issue.""" + +import math + +from design.mechanical.calc import bearings +from design.mechanical.calc import params as P +from design.mechanical.calc.budget import Verdict + + +def test_load_model_adds_weight_and_moment_couple(): + """P = W + M/span; the moment couple must dominate the tiny direct weight.""" + W = 176.5 + lever = 0.20 + span = 0.0762 + P_load = bearings.bearing_radial_load(W, lever, span) + assert math.isclose(P_load, W + (W * lever) / span, rel_tol=1e-12) + # The overturning couple is the real driver, not the direct weight. + assert (W * lever) / span > W + + +def test_l10_life_is_astronomically_large(): + """At ~1 rev/sidereal day, L10 must exceed a million years on both axes.""" + ra = bearings.l10_years(P.BRG_RA_6008, bearings.ra_output_load(P.MN78)) + dec = bearings.l10_years(P.BRG_DEC_6006, bearings.dec_output_load(P.MN78)) + assert ra > 1.0e6 + assert dec > 1.0e6 + + +def test_fatigue_is_not_the_constraint(): + """L10 dwarfs any realistic service life (>> 1000x a 100-year observatory).""" + ra = bearings.l10_years(P.BRG_RA_6008, bearings.ra_output_load(P.MN78)) + assert ra > 1000 * 100 # >> 1000 service lifetimes of 100 years each + + +def test_l10_scales_with_load_cubed(): + """Doubling the load must cut L10 revolutions by ~8x (the (C/P)^3 law).""" + base = bearings.l10_revolutions(P.BRG_RA_6008, 500.0) + doubled = bearings.l10_revolutions(P.BRG_RA_6008, 1000.0) + assert math.isclose(base / doubled, 8.0, rel_tol=1e-9) + + +def test_static_safety_is_comfortable(): + """S0 = C0/P should clear the pass gate with real margin on both axes.""" + ra_S0 = bearings.static_safety(P.BRG_RA_6008, bearings.ra_output_load(P.MN78)) + dec_S0 = bearings.static_safety(P.BRG_DEC_6006, bearings.dec_output_load(P.MN78)) + assert ra_S0 >= bearings.STATIC_SAFETY_PASS + assert dec_S0 >= bearings.STATIC_SAFETY_PASS + # Genuinely comfortable, not marginal. + assert min(ra_S0, dec_S0) > 5.0 + + +def test_evaluate_passes_with_the_angular_contact_finding(): + """Verdict PASS on load capacity, but the deep-groove caveat must be loud.""" + r = bearings.evaluate("MN78") + assert r.verdict is Verdict.PASS + assert r.safety_factor is not None and r.safety_factor >= bearings.STATIC_SAFETY_PASS + text = (r.headline + " " + " ".join(r.assumptions)).lower() + assert "deep-groove" in text + assert "7008" in text and "7006" in text + assert "angular" in text + assert "stiffness" in text + + +def test_load_is_well_below_dynamic_rating(): + """Sanity: modelled loads are a small fraction of the dynamic ratings.""" + ra_load = bearings.ra_output_load(P.MN78) + dec_load = bearings.dec_output_load(P.MN78) + assert ra_load < 0.1 * P.BRG_RA_6008.C_dynamic_N + assert dec_load < 0.1 * P.BRG_DEC_6006.C_dynamic_N diff --git a/design/mechanical/tests/test_cad.py b/design/mechanical/tests/test_cad.py new file mode 100644 index 0000000..2801655 --- /dev/null +++ b/design/mechanical/tests/test_cad.py @@ -0,0 +1,160 @@ +""" +CAD proof: the parametric geometry is driven by params.py (not hand-typed), the +GEM elevation is physically sensible, the SVG previewer actually writes a valid +file, and every OpenSCAD part carries its render command + params.py traceability. +""" + +import math +from pathlib import Path + +from design.mechanical.cad import svg_preview as sp +from design.mechanical.calc import params as P + +CAD_DIR = Path(sp.__file__).resolve().parent +SCAD_FILES = ["ra_housing.scad", "dec_housing.scad", "pier_adapter.scad", + "counterweight_shaft.scad", "assembly.scad"] + + +# -------------------------------------------------------------------------- +# Vector maths of the polar axis. +# -------------------------------------------------------------------------- +def test_polar_axis_is_unit_at_latitude(): + ux, uy = sp.polar_axis_unit(P.SITE.latitude_deg) + assert math.isclose(math.hypot(ux, uy), 1.0, rel_tol=1e-12) + # Altitude of the polar axis equals the site latitude. + assert math.isclose(math.degrees(math.atan2(uy, ux)), P.SITE.latitude_deg, rel_tol=1e-9) + + +def test_perp_vectors_are_orthogonal_to_axis(): + lat = P.SITE.latitude_deg + ax = sp.polar_axis_unit(lat) + for perp in (sp._perp_up(lat), sp._perp_down(lat)): + dot = ax[0] * perp[0] + ax[1] * perp[1] + assert abs(dot) < 1e-12 + # up side points up, down side points down. + assert sp._perp_up(lat)[1] > 0 + assert sp._perp_down(lat)[1] < 0 + + +# -------------------------------------------------------------------------- +# Geometry is sourced from params.py, not hardcoded. +# -------------------------------------------------------------------------- +def test_dimensions_come_from_params(): + g = sp.build_geometry(P.MN78) + d = g["dims"] + assert d["pier_h"] == P.PIER.height_above_m + assert d["pier_d"] == P.PIER.diameter_m + assert d["tube_len"] == P.MN78.tube_length_m + assert d["tube_od"] == P.MN78.tube_od_m + assert d["cw_len"] == P.COUNTERWEIGHTS.shaft_len_m + assert d["cw_dia"] == P.COUNTERWEIGHTS.shaft_dia_m + + +def test_ota_case_changes_geometry(): + """Swapping the OTA case must change the drawn tube — proof it's parametric.""" + assert sp.build_geometry(P.MN76)["dims"]["tube_len"] != \ + sp.build_geometry(P.MN78)["dims"]["tube_len"] + + +# -------------------------------------------------------------------------- +# The elevation is physically sensible. +# -------------------------------------------------------------------------- +def _ymin(poly): + return min(p[1] for p in poly) + + +def _ymax(poly): + return max(p[1] for p in poly) + + +def test_head_sits_above_pier_top(): + g = sp.build_geometry() + pier_top = _ymax(g["parts"]["pier"]) + # RA housing and DEC head are carried above the pier top. + assert _ymin(g["parts"]["ra_housing"]) >= pier_top - 1e-6 + assert g["anchors"]["c_dec"][1] > pier_top + + +def test_ota_reaches_toward_pole_above_dec(): + """OTA points at the pole: its top must clear the DEC head (up the polar axis).""" + g = sp.build_geometry() + assert _ymax(g["parts"]["ota_tube"]) > _ymax(g["parts"]["dec_housing"]) + + +def test_counterweight_hangs_on_the_down_side(): + """CW shaft must sit below the DEC head and on the opposite side of the polar + axis from the OTA (classical GEM). 'Sides' are measured perpendicular to the + inclined axis, not by raw x — both parts shift up the +x polar axis.""" + g = sp.build_geometry() + c_dec = g["anchors"]["c_dec"] + up = sp._perp_up(g["lat"]) # unit vector _|_ axis, OTA side + + def perp_proj(poly): + cx = sum(p[0] for p in poly) / 4.0 - c_dec[0] + cy = sum(p[1] for p in poly) / 4.0 - c_dec[1] + return cx * up[0] + cy * up[1] + + assert _ymin(g["parts"]["cw_shaft"]) < c_dec[1] # hangs below the head + assert perp_proj(g["parts"]["ota_tube"]) > 0 # OTA on the up side + assert perp_proj(g["parts"]["cw_shaft"]) < 0 # CW on the down side + + +# -------------------------------------------------------------------------- +# SVG output: valid, dimensioned with the real numbers, and it writes. +# -------------------------------------------------------------------------- +def test_render_svg_is_valid_and_dimensioned(): + svg = sp.render_svg(P.MN78) + assert svg.startswith("") + assert " 2000 + assert out.read_text(encoding="utf-8").startswith("" in assembly diff --git a/design/mechanical/tests/test_dynamics.py b/design/mechanical/tests/test_dynamics.py new file mode 100644 index 0000000..6c778cd --- /dev/null +++ b/design/mechanical/tests/test_dynamics.py @@ -0,0 +1,69 @@ +"""First-mode dynamics proof: the structural first natural frequency must sit +well above the 10 Hz target and clear of the <2 Hz wind-gust and 1-5 Hz servo +bands. Unlike the static-deflection proof (which FAILS), the light payload keeps +sqrt(k/m) high, so dynamics PASSES -- two different physics, two verdicts.""" + +import math + +from design.mechanical.calc import dynamics +from design.mechanical.calc import params as P +from design.mechanical.calc.budget import Verdict + + +def test_pier_stiffness_is_positive_and_sane(): + """A 12 in x 36 in exposed concrete pier is stiff (tens of MN/m).""" + k = dynamics.pier_lateral_stiffness() + assert 1.0e7 < k < 1.0e8 + + +def test_bounce_mode_clears_target(): + """Translational bounce mode is well above 10 Hz (pier-dominated).""" + f = dynamics.first_mode_hz(P.MN78, P.RA_HOUSING_12MM, P.DEC_HOUSING_12MM, + counterweighted=False) + assert f > P.NATURAL_FREQ_TARGET_HZ + + +def test_counterweights_lower_the_frequency(): + """Adding counterweight mass raises m_eff and lowers f_n.""" + f_free = dynamics.first_mode_hz(P.MN78, P.RA_HOUSING_12MM, P.DEC_HOUSING_12MM, + counterweighted=False) + f_cw = dynamics.first_mode_hz(P.MN78, P.RA_HOUSING_12MM, P.DEC_HOUSING_12MM, + counterweighted=True) + assert f_cw < f_free + # sqrt(m) scaling: 18 -> 30.5 kg should drop f by a factor ~sqrt(30.5/18). + assert math.isclose(f_free / f_cw, math.sqrt(30.5 / 18.0), rel_tol=0.02) + + +def test_rocking_mode_is_the_governing_lower_mode(): + """The overhung rocking mode (short bearing spans) is lower than the bounce + mode and is therefore the physically governing first mode -- yet still >10 Hz.""" + f_rock = dynamics.rocking_mode_hz(P.MN78, P.RA_HOUSING_12MM, P.DEC_HOUSING_12MM) + f_bounce = dynamics.first_mode_hz(P.MN78, P.RA_HOUSING_12MM, P.DEC_HOUSING_12MM, + counterweighted=False) + assert f_rock < f_bounce + assert f_rock > P.NATURAL_FREQ_TARGET_HZ + + +def test_governing_mode_is_the_minimum(): + g = dynamics.governing_first_mode_hz(P.MN78, P.RA_HOUSING_12MM, P.DEC_HOUSING_12MM, + counterweighted=True) + f_rock = dynamics.rocking_mode_hz(P.MN78, P.RA_HOUSING_12MM, P.DEC_HOUSING_12MM) + f_cw = dynamics.first_mode_hz(P.MN78, P.RA_HOUSING_12MM, P.DEC_HOUSING_12MM, + counterweighted=True) + assert math.isclose(g, min(f_rock, f_cw), rel_tol=1e-12) + + +def test_first_mode_clears_wind_and_servo_bands(): + """The governing mode must be well above the 2 Hz wind and 5 Hz servo bands.""" + g = dynamics.governing_first_mode_hz(P.MN78, P.RA_HOUSING_12MM, P.DEC_HOUSING_12MM, + counterweighted=True) + assert g > 5.0 * 5.0 # comfortably above the ~5 Hz servo/guide bandwidth + + +def test_evaluate_passes_above_target(): + r = dynamics.evaluate("MN78") + assert r.verdict is Verdict.PASS + assert r.safety_factor is not None and r.safety_factor > 1.5 + # Governing mode line must exist and exceed the target. + gov = [ln for ln in r.lines if ln.label == "Governing first mode"] + assert gov and gov[0].value > P.NATURAL_FREQ_TARGET_HZ diff --git a/design/mechanical/tests/test_enclosure.py b/design/mechanical/tests/test_enclosure.py new file mode 100644 index 0000000..07b06cb --- /dev/null +++ b/design/mechanical/tests/test_enclosure.py @@ -0,0 +1,100 @@ +"""Roll-off roof proof: the drive must overcome rolling friction plus the 35 mph +emergency-close gust (the wind, not the roof weight, sizes it) and traverse within +the open time; the closed flat roof carries ~6x its dead weight in snow, which is +the governing structural load and mandates a snow interlock / sloped shedding roof. +The drive PASSes with a ~2x margin over a garage-door-class opener; snow is the +loud finding.""" + +import math + +from design.mechanical.calc import enclosure +from design.mechanical.calc import params as P +from design.mechanical.calc import units as u +from design.mechanical.calc.budget import Verdict + + +def test_zero_wind_force_is_rolling_only(): + """roof_move_force_N(0) must equal mu * m * g exactly.""" + f0 = enclosure.roof_move_force_N(0.0) + expected = enclosure.ROLLING_MU * P.ENCLOSURE.roof_mass_kg * u.G0 + assert math.isclose(f0, expected, rel_tol=1e-12) + assert math.isclose(f0, 88.26, abs_tol=0.5) + + +def test_wind_adds_and_dominates_the_tractive_load(): + """At the 35 mph gust the wind drag is added and is the majority of the load.""" + gust = P.ENV.wind_gust_close_ms + f0 = enclosure.roof_move_force_N(0.0) + fg = enclosure.roof_move_force_N(gust) + f_wind = fg - f0 + assert fg > f0 + assert math.isclose(f_wind, enclosure.roof_wind_drag_N(gust), rel_tol=1e-12) + # Wind is the bigger half of the tractive force -> the drive is wind-sized. + assert f_wind > f0 + assert 0.55 < f_wind / fg < 0.70 + assert 220.0 < fg < 250.0 + + +def test_wind_drag_scales_with_velocity_squared(): + """Doubling the wind must quadruple the drag term.""" + base = enclosure.roof_wind_drag_N(5.0) + doubled = enclosure.roof_wind_drag_N(10.0) + assert math.isclose(doubled / base, 4.0, rel_tol=1e-9) + + +def test_drive_torque_is_force_times_wheel_radius(): + gust = P.ENV.wind_gust_close_ms + t = enclosure.roof_drive_torque_Nm(gust) + assert math.isclose(t, enclosure.roof_move_force_N(gust) * enclosure.DRIVE_WHEEL_RADIUS_M, rel_tol=1e-12) + assert 10.0 < t < 13.0 + + +def test_required_speed_is_feasible_and_within_timeout(): + """Traversing 3 m in the 45 s open time is a gentle speed, well under the ceiling.""" + v = enclosure.roof_required_speed_ms() + assert math.isclose(v, P.ENCLOSURE.roof_length_m / P.ENCLOSURE.open_time_s, rel_tol=1e-12) + assert 0.0 < v < enclosure.FEASIBLE_ROOF_SPEED_MS + # Nominal open time must clear the motor timeout. + assert P.ENCLOSURE.open_time_s < P.ENCLOSURE.motor_timeout_s + + +def test_snow_load_matches_pressure_times_area_and_dwarfs_dead_weight(): + """Closed-roof snow = ground snow pressure * plan area, ~6x the roof weight.""" + snow = enclosure.roof_snow_load_N() + assert math.isclose(snow, P.ENV.ground_snow_load_Pa * enclosure.roof_area_m2(), rel_tol=1e-12) + assert 10_000.0 < snow < 11_500.0 + roof_weight = u.weight_N(P.ENCLOSURE.roof_mass_kg) + assert snow > 5.0 * roof_weight # snow dominates the structure + assert snow / roof_weight > 6.0 + + +def test_snow_laden_roof_cannot_be_driven(): + """A fully snow-laden roof needs more tractive force than the drive can give -> + it must never be commanded open under snow (interlock).""" + f_snow = enclosure.snow_laden_rolling_force_N() + assert f_snow > enclosure.DRIVE_FORCE_CAP_N + # And it is far larger than the dry-roof rolling force. + assert f_snow > 5.0 * enclosure.roof_move_force_N(0.0) + + +def test_evaluate_drive_passes_with_modest_margin_and_flags_snow(): + r = enclosure.evaluate() + # Drive clears rolling + the 35 mph gust, but only ~2x over a garage-door opener. + assert r.verdict in (Verdict.PASS, Verdict.MARGINAL) + assert r.safety_factor is not None and 1.8 < r.safety_factor < 2.6 + text = (r.headline + " " + " ".join(r.assumptions)).lower() + assert "snow" in text + assert "sloped" in text or "shed" in text + assert "interlock" in text + assert "wind" in text + # The key comparison lines are present. + assert any("Total tractive force" in ln.label for ln in r.lines) + assert any("snow load" in ln.label.lower() for ln in r.lines) + + +def test_evaluate_headline_reports_wind_dominance(): + """The finding that wind (not roof weight) sizes the drive must be stated.""" + r = enclosure.evaluate() + assert any("Wind fraction" in ln.label for ln in r.lines) + wind_line = next(ln for ln in r.lines if "Wind fraction" in ln.label) + assert wind_line.value > 50.0 diff --git a/design/mechanical/tests/test_encoder.py b/design/mechanical/tests/test_encoder.py new file mode 100644 index 0000000..e7d944a --- /dev/null +++ b/design/mechanical/tests/test_encoder.py @@ -0,0 +1,93 @@ +"""Tracking-error budget proof: the as-specified encoder chain must be shown to +MISS < 1 arcsec RMS, and the on-axis absolute ring to reach it. FAIL for the +baseline is the correct, intended result.""" + +import math + +from design.mechanical.calc import encoder +from design.mechanical.calc import params as P +from design.mechanical.calc.budget import Verdict + + +def test_quantization_rms_is_lsb_over_sqrt12(): + for enc in (P.ENC_MOTOR_AMT103, P.ENC_AXIS_AS5600, P.ENC_AXIS_RESA): + assert math.isclose( + encoder.quant_rms_arcsec(enc), + enc.resolution_arcsec / math.sqrt(12.0), + rel_tol=1e-12, + ) + + +def test_as5600_on_axis_quantization_is_about_316_arcsec(): + """The cheap 12-bit on-axis chip resolves only ~316" — a coarse homing + reference, ~91" quant RMS, useless for sub-arcsec tracking.""" + res = P.ENC_AXIS_AS5600.resolution_arcsec + assert 300.0 < res < 330.0, f"AS5600 resolution {res:.1f}\" not ~316\"" + assert math.isclose(res, 316.4, abs_tol=1.0) + # Its quantisation RMS alone dwarfs the whole 1 arcsec budget. + assert encoder.quant_rms_arcsec(P.ENC_AXIS_AS5600) > 50.0 + + +def test_motor_and_resa_resolutions_bracket_the_problem(): + assert math.isclose(P.ENC_MOTOR_AMT103.resolution_arcsec, 1.58, abs_tol=0.05) + assert P.ENC_AXIS_RESA.resolution_arcsec < 0.1 # genuinely sub-arcsec + + +def test_baseline_exceeds_one_arcsec_and_fails(): + base = encoder.tracking_rms_arcsec("baseline") + assert base > 1.0, f"baseline should miss the target, got {base:.2f}\"" + # Sanity on the dominant terms: harmonic PE + flexure, not quantisation. + c = encoder.tracking_components("baseline") + assert c["pe_residual"] > c["quant"] + assert c["flexure"] > c["quant"] + # Ballpark the RSS so the model can't silently drift. + assert 4.0 < base < 6.0 + + +def test_proposed_meets_sub_arcsecond(): + prop = encoder.tracking_rms_arcsec("proposed") + assert prop < 1.0, f"proposed on-axis ring should meet target, got {prop:.2f}\"" + # RESA quantisation must be negligible vs the servo/flexure floor. + c = encoder.tracking_components("proposed") + assert c["quant"] < 0.01 + assert prop < 0.9 # real margin below 1 arcsec + + +def test_proposed_beats_baseline_by_a_wide_margin(): + assert encoder.tracking_rms_arcsec("baseline") > \ + 5.0 * encoder.tracking_rms_arcsec("proposed") + + +def test_evaluate_baseline_verdict_is_fail(): + r = encoder.evaluate("baseline") + assert r.verdict is Verdict.FAIL + assert r.safety_factor is not None and r.safety_factor < 1.0 + + +def test_evaluate_proposed_verdict_passes(): + r = encoder.evaluate("proposed") + assert r.verdict is Verdict.PASS + assert r.safety_factor is not None and r.safety_factor > 1.0 + + +def test_evaluate_compare_reports_both_and_flags_the_requirement(): + r = encoder.evaluate() # default: compare + # As-specified design fails, so the module's verdict is FAIL. + assert r.verdict is Verdict.FAIL + # Headline names both numbers and the bold conclusion. + assert "REQUIRED" in r.headline + assert "FAIL" in r.headline.upper() + # Both totals are present as lines. + labels = [ln.label for ln in r.lines] + assert any("Baseline total tracking RMS" in x for x in labels) + assert any("Proposed total tracking RMS" in x for x in labels) + # Assumptions are stated (PE residual + flexure at minimum). + joined = " ".join(r.assumptions).lower() + assert "pe" in joined or "periodic" in joined or "repeatability" in joined + assert "flexure" in joined + + +def test_target_comes_from_params_not_hardcoded(): + assert P.TRACKING_RMS_TARGET_ARCSEC == 1.0 + r = encoder.evaluate() + assert str(P.TRACKING_RMS_TARGET_ARCSEC) in r.target or "1.0" in r.target diff --git a/design/mechanical/tests/test_pier.py b/design/mechanical/tests/test_pier.py new file mode 100644 index 0000000..89498c3 --- /dev/null +++ b/design/mechanical/tests/test_pier.py @@ -0,0 +1,114 @@ +"""Pier / foundation proof: a squat 12"x36" concrete pier is stiff, high in +frequency, and stable -- all four checks PASS -- but seismic overturning relies +on soil embedment (dead weight ALONE would not resist it), the one result worth +stating plainly.""" + +import math + +from design.mechanical.calc import dynamics, pier, wind +from design.mechanical.calc import params as P +from design.mechanical.calc import units as u +from design.mechanical.calc.budget import Verdict + + +def test_pier_stiffness_matches_dynamics_single_source(): + """pier.py and dynamics.py must compute the identical pier stiffness.""" + assert math.isclose(pier.pier_lateral_stiffness_N_per_m(), + dynamics.pier_lateral_stiffness(), rel_tol=1e-12) + + +def test_pier_stiffness_is_tens_of_MN_per_m(): + k = pier.pier_lateral_stiffness_N_per_m() + assert 1.0e7 < k < 1.0e8 + + +def test_tilt_is_a_small_fraction_of_pointing_budget(): + """Operational-wind pier tilt is well under an arcsec -- << the 5" budget.""" + f = wind.drag_force_N(P.ENV.wind_gust_close_ms, P.MN78) + tilt = pier.pier_tilt_arcsec(f) + assert 0.0 < tilt < 1.0 + assert P.DEFLECTION_TARGET_ARCSEC / tilt > 5.0 + + +def test_tilt_uses_conservative_tip_slope(): + """pier_tilt_arcsec is the tip slope (F L^2/2EI) = 1.5x the delta/L estimate.""" + f = 50.0 + delta = pier.pier_tip_deflection_m(f) + naive = u.rad_to_arcsec(delta / P.PIER.height_above_m) # delta/L + slope = pier.pier_tilt_arcsec(f) + assert math.isclose(slope / naive, 1.5, rel_tol=1e-9) + + +def test_shaft_mass_derived_from_geometry(): + """Counterweight shaft mass comes from its 303-SS geometry, ~3 kg.""" + m = pier.counterweight_shaft_mass_kg() + assert 2.0 < m < 4.0 + + +def test_first_mode_far_above_target(): + """Pier substructure first mode is hundreds of Hz -- pier is not the soft element.""" + m_tip = pier.tip_mass_kg(P.MN78, counterweighted=True) + f = pier.pier_first_mode_hz(m_tip) + assert f > 5.0 * P.NATURAL_FREQ_TARGET_HZ + # Heavier tip mass lowers frequency (sqrt scaling). + f_light = pier.pier_first_mode_hz(pier.tip_mass_kg(P.MN78, counterweighted=False)) + assert f_light > f + + +def test_seismic_base_shear_scales_with_sds_and_weight(): + V = pier.seismic_base_shear_N(200.0) + assert math.isclose(V, P.ENV.seismic_sds_g * u.weight_N(200.0), rel_tol=1e-12) + assert math.isclose(pier.seismic_base_shear_N(400.0) / V, 2.0, rel_tol=1e-9) + + +def test_embedment_is_what_makes_seismic_stable(): + """Honest finding: dead weight ALONE cannot resist overturning (SF < 1); + soil passive resistance over the embedment is what provides stability.""" + M_ot = pier.seismic_overturning_Nm(P.MN78) + Pp = pier.soil_passive_resultant_N() + D = P.PIER.embed_depth_m + W_total = u.weight_N(pier.pier_total_mass_kg() + pier.tip_mass_kg(P.MN78)) + M_resist_weight = W_total * (P.PIER.diameter_m / 2.0) + M_resist_passive = Pp * (2.0 * D / 3.0) + # Dead weight alone fails; embedment brings it comfortably over 2. + assert M_resist_weight / M_ot < 1.0 + assert (M_resist_passive + M_resist_weight) / M_ot > 2.0 + + +def test_sliding_resisted_by_passive_soil(): + """Passive soil resistance dwarfs the seismic base shear.""" + m_tip = pier.tip_mass_kg(P.MN78) + V = pier.seismic_base_shear_N(m_tip + pier.pier_exposed_mass_kg()) + assert pier.soil_passive_resultant_N() / V > 2.0 + + +def test_concrete_stress_far_below_capacity(): + """Axial + flexural stresses are tiny vs f'c and the modulus of rupture.""" + I = pier.pier_second_moment_m4() + A = pier.pier_area_m2() + M = pier.seismic_overturning_Nm(P.MN78) + sigma_bend = M * (P.PIER.diameter_m / 2.0) / I + sigma_axial = u.weight_N(pier.tip_mass_kg(P.MN78)) / A + # No net tension cracking: bending tension stays under modulus of rupture. + assert (sigma_bend - sigma_axial) < pier.modulus_of_rupture_Pa() + # Compression nowhere near f'c. + assert (sigma_bend + sigma_axial) < 0.05 * P.PIER.fc_Pa + + +def test_frost_embedment_adequate(): + """0.914 m embedment exceeds the assumed central-NV frost line.""" + assert P.PIER.embed_depth_m > pier.FROST_DEPTH_TYPICAL_M + + +def test_evaluate_passes_with_seismic_governing(): + r = pier.evaluate("MN78") + assert r.verdict is Verdict.PASS + assert r.safety_factor is not None and r.safety_factor > 2.0 + # The governing (minimum) SF should be a seismic term, not tilt/frequency + # (those are enormous), so the governing SF is well under the frequency SF. + m_tip = pier.tip_mass_kg(P.MN78) + sf_freq = pier.pier_first_mode_hz(m_tip) / P.NATURAL_FREQ_TARGET_HZ + assert r.safety_factor < sf_freq + assert any("Governing safety factor" in ln.label for ln in r.lines) + # Honesty: assumptions disclose the ASSUMED seismic S_DS and soil. + assert any("S_DS" in a for a in r.assumptions) diff --git a/design/mechanical/tests/test_power.py b/design/mechanical/tests/test_power.py new file mode 100644 index 0000000..1245e2c --- /dev/null +++ b/design/mechanical/tests/test_power.py @@ -0,0 +1,104 @@ +"""Night-time energy budget proof: the load is DGX-dominated (~60%), the UPS covers +graceful shutdown with a huge margin but only ~5% of a night, and the specified +400 W / 100 Ah solar+battery gives only ~3.3 h autonomy at 12 V -> it FAILS a 10 h +winter night. A 48 V pack (4x energy) or duty-cycling the DGX fixes it. FAIL on +off-grid autonomy is the honest, important result.""" + +import math + +from design.mechanical.calc import params as P +from design.mechanical.calc import power +from design.mechanical.calc.budget import Verdict + + +def test_mount_power_uses_params_currents_and_axis_count(): + """Mount draw = V_bus * I * n_axes, with IGOTO > IRUN.""" + idle = power.mount_power_W(slewing=False) + slew = power.mount_power_W(slewing=True) + assert math.isclose(idle, power.MOTOR_BUS_V * P.MOTOR.irun_A * power.N_AXIS_MOTORS, rel_tol=1e-12) + assert math.isclose(slew, power.MOTOR_BUS_V * P.MOTOR.igoto_A * power.N_AXIS_MOTORS, rel_tol=1e-12) + assert slew > idle + assert math.isclose(idle, 36.0, abs_tol=1e-9) + + +def test_night_load_sums_all_stated_loads(): + """night_load_W must equal the sum of the itemised breakdown, ~287 W.""" + load = power.night_load_W() + assert math.isclose(load, sum(power.load_breakdown_W().values()), rel_tol=1e-12) + assert math.isclose(load, 287.0, abs_tol=1.0) + # Slewing swaps mount idle->goto, raising the load a little. + assert power.night_load_W(slewing=True) > load + + +def test_dgx_dominates_the_load(): + """The assumed 170 W DGX Spark is the majority of the night load.""" + load = power.night_load_W() + assert power.DGX_SPARK_W / load > 0.5 + # Without the DGX the load more than halves. + assert (load - power.DGX_SPARK_W) < power.DGX_SPARK_W + + +def test_ups_covers_shutdown_with_large_margin_but_not_the_night(): + """UPS rides park+close with >5x margin, yet holds only a small % of a night.""" + margin = power.ups_shutdown_margin() + assert margin > 5.0 + assert math.isclose( + margin, + power.UPS_USABLE_WH / (power.night_load_W() * power.SHUTDOWN_TIME_MIN / 60.0), + rel_tol=1e-12, + ) + # The UPS cannot ride a full winter night. + night_need_wh = power.night_load_W() * power.WINTER_NIGHT_HR + assert 0.15 * night_need_wh > power.UPS_USABLE_WH + # Its runtime at the night load is consistent with the ~30 min spec. + assert 25.0 < power.ups_runtime_min() < 40.0 + + +def test_solar_autonomy_fails_the_night_at_12v_but_passes_at_48v(): + """The decisive, unspecified parameter is pack voltage.""" + a12 = power.solar_autonomy_hours(12.0) + a48 = power.solar_autonomy_hours(48.0) + assert 3.0 < a12 < 3.7 + assert a12 < power.WINTER_NIGHT_HR # 12 V pack FAILS a 10 h night + assert a48 > power.WINTER_NIGHT_HR # 48 V pack covers it + # 48 V holds exactly 4x the energy of 12 V. + assert math.isclose(a48 / a12, 4.0, rel_tol=1e-9) + + +def test_battery_energy_scales_with_pack_voltage(): + assert math.isclose(power.battery_usable_wh(12.0), 100.0 * 12.0 * power.BATTERY_USABLE_FRAC, rel_tol=1e-12) + assert math.isclose(power.battery_usable_wh(48.0), 4.0 * power.battery_usable_wh(12.0), rel_tol=1e-12) + + +def test_winter_harvest_is_less_than_a_nights_energy(): + """The 400 W array cannot even harvest one night's worth of energy per day -> + not self-sustaining off-grid with the DGX-dominated load.""" + harvest = power.solar_daily_harvest_wh() + night_need = power.night_load_W() * power.WINTER_NIGHT_HR + assert harvest < night_need + assert 0.3 < harvest / night_need < 0.6 + + +def test_dgx_thermal_needs_ventilation(): + """170 W at a natural ~2 ACH raises the enclosure by many kelvin -> ventilate.""" + dT = power.dgx_ventilation_dT() + assert dT > 8.0 # far above a benign rise + # More air changes cut the rise (inverse in ACH). + assert power.dgx_ventilation_dT(ach=10.0) < dT + assert math.isclose(power.dgx_ventilation_dT(ach=10.0) * 10.0, + power.dgx_ventilation_dT(ach=2.0) * 2.0, rel_tol=1e-9) + + +def test_evaluate_fails_autonomy_and_names_the_culprit(): + r = power.evaluate() + # Off-grid autonomy on the specified 12 V pack does NOT hold the night. + assert r.verdict is Verdict.FAIL + assert r.safety_factor is not None and r.safety_factor < 1.0 + text = (r.headline + " " + " ".join(r.assumptions)).lower() + assert "dgx" in text + assert "48" in text # the 48 V fix is called out + assert "autonomy" in text + # Load, UPS and solar lines are all present. + assert any("Night load (steady)" in ln.label for ln in r.lines) + assert any("UPS shutdown margin" in ln.label for ln in r.lines) + assert any("Autonomy @ 12 V" in ln.label for ln in r.lines) diff --git a/design/mechanical/tests/test_report.py b/design/mechanical/tests/test_report.py new file mode 100644 index 0000000..d6372b8 --- /dev/null +++ b/design/mechanical/tests/test_report.py @@ -0,0 +1,35 @@ +""" +Single-source-of-truth guard: the committed MECHANICAL_DESIGN.md must equal what +the calculator generates right now. If a proof's number changes, the report must +be regenerated (python3 -m design.mechanical.calc.report) or this fails — so the +report can never silently disagree with the code the way the repo's docs do today. +""" + +from design.mechanical.calc import report + + +def test_report_matches_calculator(): + generated = report.build_markdown() + committed = report.OUT.read_text() + assert generated == committed, ( + "MECHANICAL_DESIGN.md is stale — regenerate with " + "`python3 -m design.mechanical.calc.report`." + ) + + +def test_every_proof_headline_present(): + """Each proof's computed headline must actually appear in the report.""" + md = report.OUT.read_text() + for _num, mod in report.PROOFS: + r = mod.evaluate() + assert r.headline in md, f"{r.key} headline missing from report" + + +def test_verdict_counts_are_honest(): + """The at-a-glance summary must reflect the real verdict tally (6 PASS / 5 FAIL).""" + from design.mechanical.calc.budget import Verdict + results = [mod.evaluate() for _num, mod in report.PROOFS] + passes = sum(1 for r in results if r.verdict is Verdict.PASS) + fails = sum(1 for r in results if r.verdict is Verdict.FAIL) + assert passes == 6 and fails == 5, f"verdict tally changed: {passes} PASS / {fails} FAIL" + assert f"**{passes} PASS" in report.OUT.read_text() diff --git a/design/mechanical/tests/test_stiffness.py b/design/mechanical/tests/test_stiffness.py new file mode 100644 index 0000000..a560f53 --- /dev/null +++ b/design/mechanical/tests/test_stiffness.py @@ -0,0 +1,77 @@ +"""Static-deflection proof: the mount head must hold the optical axis within the +5 arcsec pointing target under gravity. This is where the repo's asserted +'< 5 arcsec' is finally computed -- and it FAILS, governed by the short-span +deep-groove bearings, which is the finding worth surfacing.""" + +import math + +from design.mechanical.calc import params as P +from design.mechanical.calc import stiffness +from design.mechanical.calc.budget import Verdict + + +def test_box_section_I_thicker_wall_is_stiffer(): + """12 mm walls give a larger second moment than 8 mm walls.""" + assert stiffness.box_section_I(P.RA_HOUSING_12MM) > stiffness.box_section_I(P.RA_HOUSING) + assert stiffness.box_section_I(P.DEC_HOUSING_12MM) > stiffness.box_section_I(P.DEC_HOUSING) + + +def test_8mm_baseline_worse_than_12mm(): + """The bold 12 mm variant must deflect less than the 8 mm baseline.""" + d8 = stiffness.total_deflection_arcsec(P.MN78, P.RA_HOUSING, P.DEC_HOUSING) + d12 = stiffness.total_deflection_arcsec(P.MN78, P.RA_HOUSING_12MM, P.DEC_HOUSING_12MM) + assert d8 > d12 + + +def test_both_variants_fail_5_arcsec_target(): + """Honest finding: neither wall thickness meets 5 arcsec -- both are ~8-9x over.""" + d8 = stiffness.total_deflection_arcsec(P.MN78, P.RA_HOUSING, P.DEC_HOUSING) + d12 = stiffness.total_deflection_arcsec(P.MN78, P.RA_HOUSING_12MM, P.DEC_HOUSING_12MM) + assert d8 > P.DEFLECTION_TARGET_ARCSEC + assert d12 > P.DEFLECTION_TARGET_ARCSEC + # Both blow the budget by a large factor (bearing-dominated), not marginally. + assert d8 > 8.0 * P.DEFLECTION_TARGET_ARCSEC + assert d12 > 8.0 * P.DEFLECTION_TARGET_ARCSEC + + +def test_bearing_compliance_dominates(): + """The short 63.5/76.2 mm bearing spans, not the beams, govern the deflection.""" + b = stiffness.deflection_breakdown(P.MN78, P.RA_HOUSING, P.DEC_HOUSING) + assert b["bearing_rad"] > b["beam_rad"] + # Bearings are the overwhelming majority of the error budget. + assert b["bearing_rad"] / b["total_rad"] > 0.90 + # And within the bearings, the softer/short-span DEC (6006) dominates the RA (6008). + assert b["dec_bearing_rad"] > b["ra_bearing_rad"] + + +def test_12mm_only_helps_the_beam_term(): + """Thicker walls stiffen the beams but not the bearings, so the win is tiny.""" + b8 = stiffness.deflection_breakdown(P.MN78, P.RA_HOUSING, P.DEC_HOUSING) + b12 = stiffness.deflection_breakdown(P.MN78, P.RA_HOUSING_12MM, P.DEC_HOUSING_12MM) + # Bearing terms are identical between wall thicknesses. + assert math.isclose(b8["bearing_rad"], b12["bearing_rad"], rel_tol=1e-9) + # Beam term improves, but the total barely moves (< 1 arcsec). + assert b12["beam_rad"] < b8["beam_rad"] + assert (b8["total_arcsec"] - b12["total_arcsec"]) < 1.0 + + +def test_heavier_ota_deflects_more(): + """MN78 (18 kg payload, longer lever) must deflect more than MN76.""" + d_light = stiffness.total_deflection_arcsec(P.MN76, P.RA_HOUSING, P.DEC_HOUSING) + d_heavy = stiffness.total_deflection_arcsec(P.MN78, P.RA_HOUSING, P.DEC_HOUSING) + assert d_heavy > d_light + + +def test_total_helper_matches_breakdown(): + b = stiffness.deflection_breakdown(P.MN78, P.RA_HOUSING, P.DEC_HOUSING) + d = stiffness.total_deflection_arcsec(P.MN78, P.RA_HOUSING, P.DEC_HOUSING) + assert math.isclose(d, b["total_arcsec"], rel_tol=1e-12) + + +def test_evaluate_reports_fail_with_sf_below_one(): + r = stiffness.evaluate("MN78") + assert r.verdict is Verdict.FAIL + assert r.safety_factor is not None and r.safety_factor < 1.0 + assert any("TOTAL deflection" in ln.label for ln in r.lines) + # The assumptions must disclose the ASSUMED bearing stiffness (honesty check). + assert any("N/um" in a for a in r.assumptions) diff --git a/design/mechanical/tests/test_thermal.py b/design/mechanical/tests/test_thermal.py new file mode 100644 index 0000000..90cfaf5 --- /dev/null +++ b/design/mechanical/tests/test_thermal.py @@ -0,0 +1,107 @@ +"""Thermal focus-stability proof: the f/8 has a larger depth of focus than the +f/6 (a point in its favour), but the 6061-T6 tube walks the focus by many depths +of focus across the diurnal swing -> passive focus FAILS and a temperature- +compensated focuser is required. The Astrositall mirror term is negligible and a +sub-2 W dew heater suffices. FAIL (for passive focus) is the honest verdict.""" + +import math + +from design.mechanical.calc import params as P +from design.mechanical.calc import thermal +from design.mechanical.calc.budget import Verdict + + +def test_depth_of_focus_f8_larger_than_f6(): + """DoF = 2*lambda*N^2 -> the slower f/8 tolerates more axial error than f/6.""" + dof_f6 = thermal.depth_of_focus_um(P.MN76) + dof_f8 = thermal.depth_of_focus_um(P.MN78) + assert dof_f8 > dof_f6 + # Scales as N^2: ratio must match (8/6)^2 within tight tolerance. + assert math.isclose(dof_f8 / dof_f6, (8.0 / 6.0) ** 2, rel_tol=1e-9) + + +def test_depth_of_focus_formula_values(): + """Absolute check against 2*lambda*N^2 in micrometres.""" + lam = P.OPTICAL.wavelength_m + assert math.isclose(thermal.depth_of_focus_um(P.MN76), 2 * lam * 36 * 1e6, rel_tol=1e-9) + assert math.isclose(thermal.depth_of_focus_um(P.MN78), 2 * lam * 64 * 1e6, rel_tol=1e-9) + # ~40 um at f/6, ~70 um at f/8. + assert 39.0 < thermal.depth_of_focus_um(P.MN76) < 40.0 + assert 70.0 < thermal.depth_of_focus_um(P.MN78) < 71.0 + + +def test_tube_defocus_per_kelvin_uses_aluminium_cte_and_tube_length(): + per_k = thermal.defocus_per_kelvin_um(P.MN78) + cte = P.MATERIALS["6061-T6"].cte + assert math.isclose(per_k, cte * P.MN78.tube_length_m * 1e6, rel_tol=1e-12) + # Longer MN78 tube expands more per K than the shorter MN76 tube. + assert thermal.defocus_per_kelvin_um(P.MN78) > thermal.defocus_per_kelvin_um(P.MN76) + + +def test_tube_defocus_exceeds_dof_within_diurnal_swing(): + """The governing claim: within the diurnal swing the aluminium tube walks the + focus past the depth of focus, for BOTH tubes (f/8's larger DoF still loses).""" + swing = P.ENV.diurnal_swing_c + for ota in (P.MN76, P.MN78): + defocus_over_swing = thermal.tube_defocus_um(ota, swing) + assert defocus_over_swing > thermal.depth_of_focus_um(ota) + # Equivalent statement: fewer kelvin to defocus than the swing spans. + assert thermal.kelvin_to_defocus(ota) < swing + + +def test_only_a_couple_kelvin_before_out_of_focus(): + """K-to-defocus is only ~2 K on both tubes -> passive focus is fragile.""" + for ota in (P.MN76, P.MN78): + k_edge = thermal.kelvin_to_defocus(ota) + assert 1.5 < k_edge < 3.0 + assert k_edge < thermal.DEW_MARGIN_K # even a small dew swing defocuses it + + +def test_tube_walks_many_depths_of_focus_over_swing(): + """Over the full ~22 K swing the tube should walk ~9-11 depths of focus.""" + swing = P.ENV.diurnal_swing_c + for ota in (P.MN76, P.MN78): + n_dof = thermal.tube_defocus_um(ota, swing) / thermal.depth_of_focus_um(ota) + assert n_dof > 5.0 # comfortably more than a single DoF + assert 8.0 < n_dof < 12.0 + + +def test_astrositall_mirror_term_is_negligible(): + """The low-expansion primary contributes ~2 orders of magnitude less than the + aluminium tube; on its own the mirror stays well inside the depth of focus.""" + for ota in (P.MN76, P.MN78): + mir_per_k = thermal.mirror_defocus_per_kelvin_um(ota) + tube_per_k = thermal.defocus_per_kelvin_um(ota) + assert mir_per_k < 0.01 * tube_per_k # >100x smaller + # Mirror-only focus shift over the whole swing stays within the DoF. + assert mir_per_k * P.ENV.diurnal_swing_c < thermal.depth_of_focus_um(ota) + + +def test_dew_heater_fits_within_the_all_sky_ring(): + """Holding the corrector a few K above dewpoint costs < the 2 W ring.""" + for ota in (P.MN76, P.MN78): + w = thermal.dew_heater_power_w(ota) + assert 0.0 < w < thermal.ALL_SKY_RING_W + # P = h*A*dT relationship holds. + w = thermal.dew_heater_power_w(P.MN78, h=10.0, dT=5.0) + assert math.isclose(w, 10.0 * thermal.corrector_area_m2(P.MN78) * 5.0, rel_tol=1e-12) + + +def test_evaluate_fails_passive_focus_and_flags_the_fix(): + r = thermal.evaluate("MN78") + # Passive focus does NOT hold the swing -> honest FAIL. + assert r.verdict is Verdict.FAIL + assert r.safety_factor is not None and r.safety_factor < 1.0 + text = (r.headline + " " + " ".join(r.assumptions)).lower() + assert "compensat" in text # temperature-compensated focuser + assert "required" in text + assert "astrositall" in text + assert "dew" in text or "heater" in text + # The comparison lines must be present. + assert any("Depth of focus" in ln.label for ln in r.lines) + assert any("per K" in ln.label for ln in r.lines) + + +def test_evaluate_both_ota_keys_fail_passive(): + for key in ("MN76", "MN78"): + assert thermal.evaluate(key).verdict is Verdict.FAIL diff --git a/design/mechanical/tests/test_torque.py b/design/mechanical/tests/test_torque.py new file mode 100644 index 0000000..24312fc --- /dev/null +++ b/design/mechanical/tests/test_torque.py @@ -0,0 +1,41 @@ +"""Torque-budget proof: the drives must carry the payload with margin, and the +counterweight-free configuration must be shown feasible (or not).""" + +from design.mechanical.calc import params as P +from design.mechanical.calc import torque +from design.mechanical.calc.budget import Verdict + + +def test_counterweight_free_ra_holding_has_margin(): + """The bold claim: harmonic holding torque covers the unbalanced payload.""" + ra = torque.ra_required_torque(P.MN78, counterweight_free=True) + sf = P.RA_DRIVE.rated_torque_Nm / ra["total"] + # Must clear the payload imbalance with a real continuous margin. + assert sf >= 2.0, f"counterweight-free RA SF too low: {sf:.2f}" + # Gravity imbalance should dominate the budget (it's the whole point). + assert ra["gravity"] > ra["wind"] + assert ra["gravity"] > ra["inertia"] + + +def test_dec_drive_covers_payload(): + dec = torque.dec_required_torque(P.MN78) + assert P.DEC_DRIVE.rated_torque_Nm / dec["total"] >= 2.0 + + +def test_peak_torque_covers_goto_transient(): + ra = torque.ra_required_torque(P.MN78, counterweight_free=True) + assert P.RA_DRIVE.peak_torque_Nm / ra["total"] >= 3.0 + + +def test_heavier_ota_still_passes_but_lower_sf(): + """MN78 (heavier) must have a lower SF than MN76 — sanity on the model.""" + light = torque.ra_required_torque(P.MN76, counterweight_free=True)["total"] + heavy = torque.ra_required_torque(P.MN78, counterweight_free=True)["total"] + assert heavy > light + + +def test_evaluate_returns_pass(): + r = torque.evaluate("MN78", counterweight_free=True) + assert r.verdict in (Verdict.PASS, Verdict.MARGINAL) + assert r.safety_factor is not None and r.safety_factor > 1.5 + assert any("RA total required" in ln.label for ln in r.lines) diff --git a/design/mechanical/tests/test_tradestudy.py b/design/mechanical/tests/test_tradestudy.py new file mode 100644 index 0000000..37e219b --- /dev/null +++ b/design/mechanical/tests/test_tradestudy.py @@ -0,0 +1,163 @@ +""" +Trade-study invariants (Phase B). + +The morphological box and weighted-Pugh scores are a pure function of the score +matrix in ``build_tradestudy.py``; these tests lock the load-bearing claims the +report makes so a silent edit to a score cannot drift the narrative: + + * every weighting scheme is a valid (sums-to-1) simplex, every score in 1..5; + * the encoder baseline is DISQUALIFIED by the hard 1.0" tracking gate and can + never be selected, even though its raw weighted score is high; + * the bold counterweight-FREE GEM wins under ALL three weightings (grounded in + torque RA SF 2.5 and balance deleting 15.4 kg + 29% RA inertia); + * the three documented sensitivity FLIPS actually occur (drive, encoder, + enclosure) and the STABLE picks actually stay put (topology, pier, frame); + * the three CSVs regenerate with the expected shape. +""" + +from __future__ import annotations + +import csv +import importlib.util +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +GEN = os.path.normpath(os.path.join(HERE, "..", "tradestudy", "build_tradestudy.py")) + + +def _load(): + spec = importlib.util.spec_from_file_location("build_tradestudy", GEN) + mod = importlib.util.module_from_spec(spec) + # Register before exec so dataclass field-type resolution can find the module. + sys.modules[spec.name] = mod + spec.loader.exec_module(mod) + return mod + + +TS = _load() + + +def _sub(key): + return next(s for s in TS.SUBSYSTEMS if s.key == key) + + +def test_weighting_schemes_are_valid_simplexes(): + for name, w in TS.WEIGHTS.items(): + assert set(w) == set(TS.CRITERIA), name + assert abs(sum(w.values()) - 1.0) < 1e-9, (name, sum(w.values())) + + +def test_all_scores_in_range(): + for sub in TS.SUBSYSTEMS: + for o in sub.options: + assert set(o.scores) == set(TS.CRITERIA), o.name + for c, v in o.scores.items(): + assert 1 <= v <= 5, (o.name, c, v) + + +def test_seven_subsystems_each_with_a_bold_option(): + assert len(TS.SUBSYSTEMS) == 7 + for sub in TS.SUBSYSTEMS: + roles = {o.role for o in sub.options} + assert "baseline" in roles, sub.key + assert "bold" in roles, sub.key + + +def test_encoder_baseline_is_gated_out_despite_high_score(): + """The as-specified encoder scores well but FAILS the 1.0" target => gated.""" + enc = _sub("encoder") + base = next(o for o in enc.options if o.role == "baseline") + assert base.gate_fail is True + # Its raw weighted score is actually the highest in the subsystem ... + raw = {o.name: TS.weighted(o.scores, TS.WEIGHTS["default"]) for o in enc.options} + assert raw[base.name] == max(raw.values()), "baseline should be the raw-score leader" + # ... yet it is never selected under any weighting because of the gate. + for scheme in TS.WEIGHTS: + assert TS.selected(enc, TS.WEIGHTS[scheme]) is not base + + +def test_encoder_selection_requires_on_axis_feedback(): + """Every qualifying encoder option carries an on-axis absolute element.""" + enc = _sub("encoder") + for scheme in TS.WEIGHTS: + sel = TS.selected(enc, TS.WEIGHTS[scheme]) + assert "on-axis" in sel.name.lower(), (scheme, sel.name) + + +def test_counterweight_free_gem_wins_everywhere(): + """The bold topology pick is robust across all three weightings.""" + top = _sub("topology") + bold = next(o for o in top.options if o.role == "bold") + assert "FREE" in bold.name + for scheme in TS.WEIGHTS: + assert TS.selected(top, TS.WEIGHTS[scheme]) is bold, scheme + + +def test_documented_sensitivity_flips_occur(): + """Precision-heavy weighting must flip drive, encoder, and enclosure.""" + d, p = TS.WEIGHTS["default"], TS.WEIGHTS["precision"] + + drive = _sub("drive") + assert TS.selected(drive, d).role == "baseline" # NEMA17+planetary+harmonic + assert TS.selected(drive, p).name == "Torque-motor direct drive" + + enc = _sub("encoder") + assert "Hybrid" in TS.selected(enc, d).name + assert TS.selected(enc, p).name == "On-axis high-res absolute ring" + + enc_sub = _sub("enclosure") + assert TS.selected(enc_sub, d).role == "baseline" # plain roll-off + assert TS.selected(enc_sub, p).name == "Roll-off + active thermal" + + +def test_stable_picks_do_not_move(): + """Topology, pier, and frame winners are weighting-invariant.""" + for key, want in [("topology", "Counterweight-FREE GEM"), + ("pier", "Concrete Sonotube"), + ("frame", "6061-T6 CNC plates")]: + sub = _sub(key) + picks = {TS.selected(sub, TS.WEIGHTS[s]).name for s in TS.WEIGHTS} + assert picks == {want}, (key, picks) + + +def test_mechanical_ota_winner_is_the_light_in_production_tube(): + """On the six mechanical axes alone, the lightest in-production tube wins; + the program's retention of the heavier MN78 is a science override, not a + mechanical result (this is why the stiffness proof FAILs on MN78).""" + ota = _sub("ota") + for scheme in TS.WEIGHTS: + assert TS.selected(ota, TS.WEIGHTS[scheme]).name == "ES-MN152 f/4.8", scheme + mn78 = next(o for o in ota.options if o.role == "baseline") + mn76 = next(o for o in ota.options if "MN76" in o.name) + # Among the sourced Intes pair, the lighter MN76 out-scores the doc-selected MN78. + assert (TS.weighted(mn76.scores, TS.WEIGHTS["default"]) + > TS.weighted(mn78.scores, TS.WEIGHTS["default"])) + + +def test_csvs_regenerate_with_expected_shape(): + p1 = TS.write_morphological() + p2 = TS.write_pugh() + p3 = TS.write_sensitivity() + n_opts = sum(len(s.options) for s in TS.SUBSYSTEMS) + + for path in (p1, p2, p3): + assert os.path.exists(path) + + with open(p1) as fh: + rows = list(csv.reader(fh)) + assert rows[0] == ["subsystem", "option", "role", "description", "proof_citation"] + assert len(rows) == 1 + n_opts + + with open(p2) as fh: + rows = list(csv.DictReader(fh)) + assert len(rows) == n_opts + # Exactly one SELECTED per subsystem (7 total). + assert sum(1 for r in rows if r["selected"] == "SELECTED") == 7 + assert sum(1 for r in rows if r["gate"] == "DISQUALIFIED") == 1 + + with open(p3) as fh: + rows = list(csv.DictReader(fh)) + assert len(rows) == n_opts + for scheme_col in ("win_default", "win_precision", "win_cost"): + assert sum(1 for r in rows if r[scheme_col] == "*") == 7 diff --git a/design/mechanical/tests/test_wind.py b/design/mechanical/tests/test_wind.py new file mode 100644 index 0000000..4b6520d --- /dev/null +++ b/design/mechanical/tests/test_wind.py @@ -0,0 +1,97 @@ +"""Wind-load proof: this module OWNS the authoritative drag calc. Two regimes -- +tiny operational drag (roof open, 35 mph max) feeding the torque/pier budgets, +and the governing survival case (roof closed, 105 mph) where the near-flat roof +develops uplift far beyond its own weight -- an honest FAIL that surfaces the +repo's missing hold-down/anchorage spec.""" + +import math + +from design.mechanical.calc import params as P +from design.mechanical.calc import torque, wind +from design.mechanical.calc.budget import Verdict + + +def test_dynamic_pressure_matches_half_rho_v2(): + V = P.ENV.wind_gust_close_ms + q = wind.dynamic_pressure_Pa(V) + assert math.isclose(q, 0.5 * P.SITE.air_density * V ** 2, rel_tol=1e-12) + # At 1800 m the air is thinner than sea level -> q below the 1.225 figure. + assert q < 0.5 * 1.225 * V ** 2 + + +def test_drag_grows_with_square_of_speed(): + """Doubling wind speed quadruples drag.""" + f1 = wind.drag_force_N(10.0, P.MN78) + f2 = wind.drag_force_N(20.0, P.MN78) + assert math.isclose(f2 / f1, 4.0, rel_tol=1e-9) + + +def test_drag_is_authoritative_and_matches_torque_copy(): + """wind.py owns the drag; torque.py's private copy must agree exactly.""" + for V in (P.ENV.wind_park_ms, P.ENV.wind_gust_close_ms): + for ota in (P.MN76, P.MN78): + f_wind = wind.drag_force_N(V, ota) + f_torque = torque._drag_force(V, ota, P.ENV, P.SITE) + assert math.isclose(f_wind, f_torque, rel_tol=1e-12) + + +def test_operational_drag_is_small_but_heavier_tube_sees_more(): + """35 mph gust drag on the OTA is only tens of newtons; MN78 > MN76 (bigger tube).""" + f_gust_78 = wind.drag_force_N(P.ENV.wind_gust_close_ms, P.MN78) + f_gust_76 = wind.drag_force_N(P.ENV.wind_gust_close_ms, P.MN76) + assert 20.0 < f_gust_78 < 60.0 + assert f_gust_78 > f_gust_76 # larger projected area + + +def test_park_threshold_lower_than_gust(): + assert wind.drag_force_N(P.ENV.wind_park_ms, P.MN78) < \ + wind.drag_force_N(P.ENV.wind_gust_close_ms, P.MN78) + + +def test_wind_moment_base_exceeds_top(): + """Moment at the pier base (longer lever) exceeds the pier-top moment.""" + ota = P.MN78 + f = wind.drag_force_N(P.ENV.wind_gust_close_ms, ota) + m_base = wind.ota_wind_moment_Nm(P.ENV.wind_gust_close_ms, ota) + m_top = f * wind.MOUNT_HEAD_HEIGHT_M + assert m_base > m_top + assert math.isclose(m_base, f * (P.PIER.height_above_m + wind.MOUNT_HEAD_HEIGHT_M), + rel_tol=1e-12) + + +def test_roof_uplift_formula_and_magnitude(): + """Survival uplift = q_survival * GCp * A_roof, several kN on the 9 m^2 roof.""" + q = wind.dynamic_pressure_Pa(P.ENV.survival_wind_ms) + expect = q * P.ENV.roof_uplift_gcp * (P.ENCLOSURE.roof_span_m * P.ENCLOSURE.roof_length_m) + assert math.isclose(wind.roof_uplift_N(), expect, rel_tol=1e-12) + assert 8.0e3 < wind.roof_uplift_N() < 11.0e3 + + +def test_roof_selfweight_cannot_resist_uplift(): + """The headline finding: roof dead weight resists only ~1/5 of the uplift.""" + sf = wind.roof_weight_N() / wind.roof_uplift_N() + assert sf < 0.3 + # Net uplift the (unspecified) anchors must carry is the bulk of the gross. + assert wind.roof_net_uplift_N() > 0.7 * wind.roof_uplift_N() + + +def test_assumed_anchors_would_remediate(): + """Four typical 2 klbf anchors turn the FAIL into a real margin -- the fix.""" + capacity = wind.ROOF_ANCHOR_COUNT * wind.ROOF_ANCHOR_ALLOW_N + assert capacity / wind.roof_uplift_N() > 2.0 + + +def test_survival_governs_over_operational(): + """Survival roof uplift dwarfs the operational gust drag by orders of magnitude.""" + f_gust = wind.drag_force_N(P.ENV.wind_gust_close_ms, P.MN78) + assert wind.roof_uplift_N() > 100.0 * f_gust + + +def test_evaluate_fails_on_survival_uplift(): + """As-specified (no anchors in repo) the roof FAILS survival uplift -- the + important, honest result. Assumptions must disclose the ASSUMED survival wind.""" + r = wind.evaluate("MN78") + assert r.verdict is Verdict.FAIL + assert r.safety_factor is not None and r.safety_factor < 1.0 + assert any("uplift" in ln.label.lower() for ln in r.lines) + assert any("ASSUMED" in a and "mph" in a for a in r.assumptions) diff --git a/design/mechanical/tradestudy/SECTION.md b/design/mechanical/tradestudy/SECTION.md new file mode 100644 index 0000000..728a098 --- /dev/null +++ b/design/mechanical/tradestudy/SECTION.md @@ -0,0 +1,183 @@ +## Trade Study (Phase B) — Morphological Box + Weighted-Pugh Selection + +This section does not compute physics; it **selects** among design permutations by +scoring them against the verdicts the Phase-C proof modules actually computed, so +the configuration that goes into CAD is grounded rather than asserted. The scoring +matrix, the three CSVs below, and their invariants are a pure function encoded in +`design/mechanical/tradestudy/build_tradestudy.py` and locked by +`design/mechanical/tests/test_tradestudy.py` (10 tests, green). + +**Grounding — the Phase-C verdict ledger the scores are anchored to:** + +| Proof | Verdict | Governing number | +|---|---|---| +| torque | PASS | counterweight-FREE MN78: RA SF 2.5, DEC SF 2.6 vs rated | +| stiffness | **FAIL** | 43.3" vs 5" target — bearing compliance at 63.5/76.2 mm spans governs | +| dynamics | PASS | first mode 65 Hz vs 10 Hz (SF 6.5) | +| encoder | **FAIL** | baseline 5.02" RMS vs 1.0"; on-axis RESA ring 0.54" | +| balance | PASS | 12.5 kg balances 18 kg (fit 1.59); CW-free deletes 15.4 kg + 29% RA inertia | +| bearings | PASS + finding | load never governs; 60xx are deep-groove — use angular-contact 7008/7006 pairs | +| wind | **FAIL** | roof uplift SF 0.19 — hold-down anchors mandatory (SF 3.9 with 4x 2 klbf) | +| pier | PASS | governing SF 6.8, f_n 152 Hz | +| thermal | **FAIL** (passive) | 22 K swing walks focus ~10x DoF — temp-compensated focuser required | +| enclosure | PASS + finding | drive SF 2.1; snow case governs the roof, snow interlock required | +| power | **FAIL** (off-grid) | 12 V pack 3.3 h vs 10 h night; 48 V -> 13.4 h; DGX = 59% of load | + +**Scoring convention.** Every criterion is *higher-is-better* on a 1–5 scale (for +cost and sourcing, 5 = cheapest / lowest risk), so a weighted score stays in 1–5. +Default weights: **stiffness 0.22, precision 0.22, cost 0.15, buildability 0.15, +thermal 0.10, sourcing-risk 0.16**. Crucially, precision against the hard 1.0" +tracking target is a **pass/fail GATE**, not merely a weighted term: an option that +fails it is *disqualified* regardless of its weighted score. + +### 1. Morphological (Zwicky) matrix — the option space + +`morphological.csv`. Bold picks are marked **[BOLD]**. + +| Subsystem | Baseline | Alternatives | Bold | +|---|---|---|---| +| OTA | MN78 f/8 (14 kg) | MN76 f/6 (9 kg), MN86 8", ES-MN152 | APM-LZOS apo triplet | +| Mount topology | Counterweighted GEM | Fork + derotator | **Counterweight-FREE GEM** | +| Axis drive | NEMA17 + 27:1 + harmonic | Direct-to-harmonic | **Torque-motor direct drive** | +| Encoder | AMT103 + AS5600 | Hybrid (motor + on-axis abs.) | **On-axis high-res absolute ring** | +| Pier | Concrete Sonotube | Steel-concrete hybrid | **Isolated pier-in-pier** | +| Enclosure | Roll-off roof | Clamshell dome | **Roll-off + active thermal** | +| Frame | 6061-T6 CNC plates | Steel weldment | **Cast housings** | + +### 2. Weighted-Pugh scores (default weights) + +`pugh_scores.csv`. Winner of each subsystem in **bold**; the encoder baseline is +`DISQUALIFIED` by the tracking gate. + +| Subsystem | Option | stiff | prec | cost | build | therm | src | **Weighted** | +|---|---|:-:|:-:|:-:|:-:|:-:|:-:|:-:| +| OTA | MN78 f/8 (baseline) | 2 | 4 | 3 | 3 | 4 | 2 | 2.94 | +| OTA | MN76 f/6 | 4 | 3 | 3 | 4 | 3 | 2 | 3.21 | +| OTA | MN86 8" | 1 | 4 | 2 | 2 | 3 | 1 | 2.16 | +| OTA | APM-LZOS **[BOLD]** | 2 | 5 | 1 | 3 | 3 | 2 | 2.76 | +| OTA | **ES-MN152** | 4 | 3 | 5 | 4 | 3 | 5 | **3.99** | +| Topology | Counterweighted GEM (baseline) | 3 | 3 | 3 | 4 | 3 | 4 | 3.31 | +| Topology | **Counterweight-FREE GEM [BOLD]** | 3 | 4 | 4 | 4 | 3 | 4 | **3.68** | +| Topology | Fork + derotator | 3 | 2 | 2 | 2 | 3 | 2 | 2.32 | +| Drive | **NEMA17 + 27:1 + harmonic (baseline)** | 3 | 2 | 4 | 4 | 3 | 4 | **3.24** | +| Drive | Direct-to-harmonic | 3 | 3 | 3 | 3 | 3 | 4 | 3.16 | +| Drive | Torque-motor DD **[BOLD]** | 3 | 5 | 1 | 2 | 2 | 2 | 2.73 | +| Encoder | AMT103 + AS5600 (baseline) | 3 | 1 | 5 | 4 | 3 | 5 | 3.33 `DISQ` | +| Encoder | On-axis RESA ring **[BOLD]** | 3 | 5 | 1 | 2 | 3 | 2 | 2.83 | +| Encoder | **Hybrid (motor + on-axis abs.)** | 3 | 4 | 2 | 3 | 3 | 3 | **3.07** | +| Pier | **Concrete Sonotube (baseline)** | 4 | 3 | 5 | 4 | 4 | 5 | **4.09** | +| Pier | Isolated pier-in-pier **[BOLD]** | 4 | 4 | 3 | 3 | 4 | 4 | 3.70 | +| Pier | Steel-concrete hybrid | 3 | 3 | 3 | 3 | 2 | 3 | 2.90 | +| Enclosure | **Roll-off roof (baseline)** | 3 | 3 | 5 | 5 | 3 | 5 | **3.92** | +| Enclosure | Clamshell dome | 3 | 3 | 2 | 2 | 2 | 2 | 2.44 | +| Enclosure | Roll-off + active thermal **[BOLD]** | 3 | 4 | 4 | 4 | 5 | 4 | 3.88 | +| Frame | **6061-T6 CNC plates (baseline)** | 3 | 3 | 4 | 4 | 4 | 4 | **3.56** | +| Frame | Steel weldment | 4 | 3 | 3 | 2 | 3 | 3 | 3.07 | +| Frame | Cast housings **[BOLD]** | 4 | 4 | 2 | 1 | 3 | 2 | 2.83 | + +Two results are worth pausing on. First, the **encoder baseline has the *highest* +raw weighted score in its subsystem (3.33) yet is not selectable** — it fails the +1.0" gate (encoder proof: 5.02" RMS). A naive weighted sum would have kept a design +that misses the headline requirement by 5x; the gate is what prevents that, and it +is the single most important structural feature of this study. Second, the **OTA +math prefers the light in-production ES-MN152 (3.99), and among the Intes pair MN76 +(3.21) out-scores the doc-'selected' MN78 (2.94)** — MN78 is the *worst* realistic +tube on the six mechanical axes, precisely because its 14 kg / 1.4 m tube is the +lever the stiffness proof FAILs on. + +### 3. Sensitivity — precision-heavy and cost-heavy re-weighting + +`sensitivity.csv`. Precision-heavy = {prec 0.40, stiff 0.20, cost 0.10, build 0.10, +therm 0.08, src 0.12}; cost-heavy = {cost 0.35, src 0.20, build 0.15, stiff 0.12, +prec 0.10, therm 0.08}. + +| Subsystem | Default winner | Precision-heavy | Cost-heavy | Robust? | +|---|---|---|---|---| +| OTA | ES-MN152 (3.99) | ES-MN152 (3.74) | ES-MN152 (4.37) | **stable** | +| Topology | CW-FREE GEM (3.68) | CW-FREE GEM (3.72) | CW-FREE GEM (3.80) | **stable** | +| Drive | NEMA17+harmonic (3.24) | **Torque-motor DD (3.30)** | NEMA17+harmonic (3.60) | flips (prec) | +| Encoder | Hybrid (3.07) | **RESA ring (3.38)** | Hybrid (2.75) | flips (prec) | +| Pier | Sonotube (4.09) | Sonotube (3.82) | Sonotube (4.45) | **stable** | +| Enclosure | Roll-off (3.92) | **Roll-off + thermal (3.88)** | Roll-off (4.40) | flips (prec) | +| Frame | 6061 CNC (3.56) | 6061 CNC (3.40) | 6061 CNC (3.56/3.78) | **stable** | + +The winner changes in exactly three subsystems, and **all three flips are in the +same direction**: weighting the sub-arcsec mission more heavily pulls the drive to a +zero-gear-PE torque motor, the encoder to the full on-axis ring, and the enclosure +to active thermal control — i.e. the bold options are the *precision* options, and +they win the moment precision dominates. Four subsystems (OTA, topology, pier, +frame) never move. + +### 4. Selected permutation + rationale + +The delivered build takes the default-weight winners, with two deliberate, +disclosed departures. It comprises **4 baselines, 2 bold picks, and 1 mandatory +bold-adjacent upgrade**: + +1. **OTA — MN78 f/8 (science override of the mechanical winner).** The mechanical + Pugh winner is ES-MN152 under every weighting, and MN76 beats MN78 among the + Intes pair. MN78 is retained for its f/8 imaging scale and 0.134 obstruction — + an optical/science requirement that lives *outside* the six mechanical criteria. + This retention is not free: it is exactly why the stiffness proof FAILs, and it + is the reason item 8 below is mandatory. If the bearing remediation proves + insufficient, **MN76 is the drop-in mechanical hedge** (shorter CG lever, lower + RA torque). +2. **Mount topology — Counterweight-FREE GEM [BOLD].** Robust winner under all three + weightings. Grounded: torque proof gives RA SF 2.5 / DEC SF 2.6 on the harmonic + drives' back-drive resistance, and the balance proof shows deleting the shaft + + weights removes **15.4 kg and 29% of the RA inertia** — a strict improvement in + what the drives and pier must carry, the same principle the ZWO AM5 / RST-135 + exploit. +3. **Axis drive — NEMA17 + 27:1 planetary + harmonic (baseline).** Wins default and + cost-heavy; torque proof PASS. The planetary's periodic error — the only reason + to consider deleting it — is **mooted by the on-axis encoder (item 4), which + corrects everything upstream of the axis**, so the cheap standard OnStepX + drivetrain is retained. (Under a precision-only view it flips to the torque + motor; that is a documented, deferred upgrade path.) +4. **Encoder — Hybrid: motor encoder (velocity) + on-axis absolute (position) + [mandatory on-axis upgrade].** The as-specified baseline is **DISQUALIFIED** — + encoder proof: 5.02" RMS, 5x over target, because the AS5600's ~91" quantisation + makes it homing-grade only and the servo must close on the motor encoder, which + cannot see harmonic PE or mount flexure. Some form of on-axis absolute feedback + is *required*, not optional (proof: on-axis ring reaches 0.54" RMS). The hybrid + wins among qualifying options at default/cost weights; the **full on-axis RESA + ring [BOLD]** is the selected upgrade when precision is weighted heavily and is + the only option that reaches sub-arcsec outright. +5. **Pier — Concrete Sonotube (baseline).** Wins under all weightings (4.09). The + pier proof already passes with governing SF 6.8 and f_n 152 Hz, so the bold + isolated pier-in-pier would be **gold-plating the one part that is not the + constraint** — the head/bearings are. Retained as baseline. +6. **Enclosure — Roll-off + active thermal [BOLD].** A statistical tie with the + plain roll-off at default weights (3.88 vs 3.92, within noise), broken toward the + bold variant by two proof findings the plain roof ignores: the **thermal FAIL** + (22 K diurnal swing walks focus ~10x depth-of-focus) and the **power finding** + that the DGX dumps ~14 K into the enclosure at 2 ACH ("ventilate or locate + outside"). Active ventilation + insulation + day pre-cooling is the direct remedy, + and it becomes the outright winner the instant precision is weighted up. +7. **Frame — 6061-T6 CNC plates (baseline).** Wins under all weightings (3.56). The + key insight from the stiffness proof: the **aluminium plates are *not* the + governing compliance — the bearings are** — so switching to a steel weldment or + cast housings spends stiffness budget in the wrong place. Retain the machinable, + DIY-friendly 6061 frame and fix the bearings instead (item 8). + +**Cross-cutting actions the trade study surfaces but cannot select away.** Three +Phase-C FAILs are not closable by any of the seven morphological axes and are +booked here as required detail-design actions: + +- **Stiffness (governing FAIL, 43.3" vs 5"):** fix at the *component* level, not the + configuration level — replace the deep-groove 6008/6006 with **matched + angular-contact 7008/7006 pairs (back-to-back) on larger spans** (bearings + + stiffness proofs). No frame or topology choice in the box moves this number. +- **Wind (FAIL, roof uplift SF 0.19):** hold-down anchors are **mandatory and + currently unspecified** — 4x 2 klbf anchors restore SF 3.9 (wind proof). Pair with + the snow interlock the enclosure proof requires. +- **Off-grid power (FAIL):** the selected build assumes grid + UPS (16x shutdown + margin). True autonomy needs a **48 V pack (13.4 h) or a DGX duty-cycle**, not the + specified 12 V / 100 Ah pack (3.3 h). Orthogonal to the enclosure/pier choices. + +**Net:** the selected permutation banks the two clearly-won bold improvements +(counterweight-free GEM, active-thermal enclosure) plus the mandatory on-axis +encoder, keeps the baseline where the baseline already passes (drive, pier, frame), +and honestly flags that the OTA choice, the stiffness fix, the wind anchors, and the +power autonomy are decisions the science mission and detail design must own — the +trade study cannot make them disappear. diff --git a/design/mechanical/tradestudy/build_tradestudy.py b/design/mechanical/tradestudy/build_tradestudy.py new file mode 100644 index 0000000..17c5c54 --- /dev/null +++ b/design/mechanical/tradestudy/build_tradestudy.py @@ -0,0 +1,297 @@ +""" +NIGHTWATCH mechanical trade study — morphological (Zwicky) box + weighted Pugh. + +This is Phase B of the mechanical-design proof. It does NOT compute physics; it +*scores* the design permutations against the verdicts the Phase-C proof modules +(torque, stiffness, dynamics, encoder, balance, bearings, wind, pier, thermal, +enclosure, power) actually computed, so the selection is grounded, not asserted. + +Scoring convention +------------------ +Every criterion is "higher is better" on a 1..5 scale (5 = best): + 1 poor · 2 below par · 3 adequate/baseline · 4 good · 5 excellent +For the cost and sourcing criteria, 5 therefore means *cheapest* / *lowest +sourcing risk*. A weighted score is sum(weight_i * score_i), so it stays in 1..5. + +Requirement gate +---------------- +A weighted score can rank an option that FAILS a hard requirement above one that +meets it (see the encoder subsystem). Precision against the 1.0" tracking target +is a pass/fail GATE, not merely a weighted criterion: any option that fails the +gate is DISQUALIFIED from selection regardless of its weighted score. This is the +central lesson of the study and is encoded explicitly (``gate_fail``). + +Outputs (written next to this file): + morphological.csv the Zwicky box: every option, its role, proof citation + pugh_scores.csv the six criteria scores + default weighted total + rank + sensitivity.csv default vs precision-heavy vs cost-heavy re-ranking +""" + +from __future__ import annotations + +import csv +import os +from dataclasses import dataclass, field + +# -------------------------------------------------------------------------- +# Criteria and the three weighting schemes (each sums to 1.0). +# -------------------------------------------------------------------------- +CRITERIA = ["stiffness", "precision", "cost", "buildability", "thermal", "sourcing"] + +WEIGHTS = { + # Task default weights. + "default": {"stiffness": 0.22, "precision": 0.22, "cost": 0.15, + "buildability": 0.15, "thermal": 0.10, "sourcing": 0.16}, + # Precision-heavy: the sub-arcsec imaging mission dominates. + "precision": {"stiffness": 0.20, "precision": 0.40, "cost": 0.10, + "buildability": 0.10, "thermal": 0.08, "sourcing": 0.12}, + # Cost-heavy: amateur / grant-limited build, minimise spend and sourcing risk. + "cost": {"stiffness": 0.12, "precision": 0.10, "cost": 0.35, + "buildability": 0.15, "thermal": 0.08, "sourcing": 0.20}, +} +for name, w in WEIGHTS.items(): + assert abs(sum(w.values()) - 1.0) < 1e-9, (name, sum(w.values())) + + +@dataclass +class Option: + name: str + role: str # baseline | alternative | bold + desc: str # one-line morphological description + proof: str # grounding citation from the Phase-C proofs + scores: dict # criterion -> 1..5 + gate_fail: bool = False # disqualified: fails a hard requirement + + +@dataclass +class Subsystem: + key: str + title: str + options: list = field(default_factory=list) + + +def S(stiffness, precision, cost, build, thermal, sourcing): + return {"stiffness": stiffness, "precision": precision, "cost": cost, + "buildability": build, "thermal": thermal, "sourcing": sourcing} + + +# ========================================================================== +# The morphological matrix (Zwicky box): 7 subsystems, each baseline + alts. +# Scores are engineering judgement anchored to the proof verdicts cited. +# ========================================================================== +SUBSYSTEMS: list[Subsystem] = [ + Subsystem("ota", "Optical tube assembly", [ + Option("MN78 f/8 (14 kg)", "baseline", + "180 mm Mak-Newt, 1440 mm tube, 0.134 obstruction; doc-'selected'", + "stiffness: MN78 is the tube the FAIL is computed on (43.3\" vs 5\")", + S(2, 4, 3, 3, 4, 2)), + Option("MN76 f/6 (9 kg)", "alternative", + "178 mm Mak-Newt, 700 mm tube, 0.25 obstruction; lighter/shorter", + "torque: lighter tube => lower RA torque; stiffness: shorter CG lever", + S(4, 3, 3, 4, 3, 2)), + Option("MN86 f/6 (8-inch)", "alternative", + "203 mm Mak-Newt; more aperture, heavier and longer tube", + "stiffness/torque: heaviest tube => worst mount loading", + S(1, 4, 2, 2, 3, 1)), + Option("APM-LZOS apo triplet", "bold", + "refractor, zero obstruction, premium optics; long/heavy/costly", + "thermal: glass soak vs metal-tube focus walk; precision: no obstruction", + S(2, 5, 1, 3, 3, 2)), + Option("ES-MN152 f/4.8", "alternative", + "152 mm Mak-Newt, in-production mass-market; cheap/light", + "torque/stiffness: lightest, in-production; lowest sourcing risk", + S(4, 3, 5, 4, 3, 5)), + ]), + Subsystem("topology", "Mount topology", [ + Option("Counterweighted GEM", "baseline", + "German equatorial + 12.5 kg counterweight on 457 mm shaft", + "balance: 12.5 kg balances 18 kg payload at r=288 mm (fit 1.59)", + S(3, 3, 3, 4, 3, 4)), + Option("Counterweight-FREE GEM", "bold", + "delete the shaft+weights; harmonic back-drive holds the imbalance", + "torque: RA SF 2.5 PASS; balance: deletes 15.4 kg + 29% RA inertia", + S(3, 4, 4, 4, 3, 4)), + Option("Fork + field derotator", "alternative", + "symmetric fork, no meridian flip; derotator adds a rotation axis", + "precision: derotator injects a continuous field-rotation error term", + S(3, 2, 2, 2, 3, 2)), + ]), + Subsystem("drive", "Axis drive train", [ + Option("NEMA17 + 27:1 + harmonic", "baseline", + "stepper + planetary + CSF harmonic (100:1 RA / 80:1 DEC)", + "torque: PASS; encoder: on-axis ring moots upstream planetary PE", + S(3, 2, 4, 4, 3, 4)), + Option("Direct-to-harmonic", "alternative", + "delete planetary; larger motor straight into the harmonic", + "torque: 0.45 Nm x100 = 45 Nm ~ 49.9 Nm need => bigger motor required", + S(3, 3, 3, 3, 3, 4)), + Option("Torque-motor direct drive", "bold", + "frameless torque motor on-axis, zero gear PE; needs on-axis encoder", + "encoder: only DD+ring reaches seeing-limited; power: holding-heat cost", + S(3, 5, 1, 2, 2, 2)), + ]), + Subsystem("encoder", "Feedback / encoder chain", [ + Option("AMT103 motor + AS5600 axis", "baseline", + "fine motor encoder + 12-bit on-axis chip (homing-grade)", + "encoder: 5.02\" RMS FAILS the 1.0\" target => DISQUALIFIED", + S(3, 1, 5, 4, 3, 5), gate_fail=True), + Option("On-axis high-res absolute ring", "bold", + "Renishaw RESA-class absolute ring, sub-arcsec LSB, on the axis", + "encoder: 0.54\" RMS => MEETS sub-arcsec (only option that does)", + S(3, 5, 1, 2, 3, 2)), + Option("Hybrid (motor + on-axis absolute)", "alternative", + "motor encoder for velocity + on-axis absolute for position correction", + "encoder: on-axis correction closes the PE+flexure gap the motor can't see", + S(3, 4, 2, 3, 3, 3)), + ]), + Subsystem("pier", "Pier / foundation", [ + Option("Concrete Sonotube", "baseline", + "12-inch x 36-inch concrete pier on a 36-inch embedment", + "pier: governing SF 6.8 PASS, f_n 152 Hz (SF 15)", + S(4, 3, 5, 4, 4, 5)), + Option("Isolated pier-in-pier", "bold", + "inner OTA pier isolated from the building slab / enclosure floor", + "pier: core stiffness unchanged; isolates roof-drive/footfall vibration", + S(4, 4, 3, 3, 4, 4)), + Option("Steel-concrete hybrid", "alternative", + "steel column on a concrete footing; faster to erect", + "pier: slender steel column => lower f_n; thermal bending of the column", + S(3, 3, 3, 3, 2, 3)), + ]), + Subsystem("enclosure", "Enclosure", [ + Option("Roll-off roof", "baseline", + "flat roll-off roof, garage-door-class drive", + "enclosure: drive SF 2.1 PASS; wind: uplift SF 0.19 => anchors mandatory", + S(3, 3, 5, 5, 3, 5)), + Option("Clamshell dome", "alternative", + "rotating split-shell dome; better wind screen, worse flush", + "thermal: enclosed dome traps the DGX 14 K heat dump", + S(3, 3, 2, 2, 2, 2)), + Option("Roll-off + active thermal", "bold", + "roll-off base + insulation + forced ventilation + day pre-cooling", + "thermal: FAIL focus-walk remedy; power: exhausts DGX 14 K enclosure dump", + S(3, 4, 4, 4, 5, 4)), + ]), + Subsystem("frame", "Mount-head frame / housings", [ + Option("6061-T6 CNC plates", "baseline", + "bolt-together CNC aluminium housings (E=68.9 GPa)", + "stiffness: plates are NOT the governing compliance (bearings are)", + S(3, 3, 4, 4, 4, 4)), + Option("Steel weldment", "alternative", + "welded steel frame, ~3x modulus; heavier, weld distortion", + "stiffness: 3x E cuts only the small beam term; bearings still govern", + S(4, 3, 3, 2, 3, 3)), + Option("Cast housings", "bold", + "cast iron/Al monolithic housings; best damping, integral bearing seats", + "stiffness: integral large-span AC bearing seats attack the FAIL; foundry risk", + S(4, 4, 2, 1, 3, 2)), + ]), +] + + +# ========================================================================== +# Scoring +# ========================================================================== +def weighted(scores: dict, weights: dict) -> float: + return round(sum(weights[c] * scores[c] for c in CRITERIA), 4) + + +def ranked(sub: Subsystem, weights: dict, apply_gate: bool = True): + """Return options sorted best-first under a weighting. + + Gate-failed options are pushed below all qualifying options (they cannot be + selected) but keep their raw weighted score for transparency. + """ + def keyfn(o: Option): + return (0 if (apply_gate and o.gate_fail) else 1, weighted(o.scores, weights)) + return sorted(sub.options, key=keyfn, reverse=True) + + +def selected(sub: Subsystem, weights: dict) -> Option: + return ranked(sub, weights, apply_gate=True)[0] + + +# ========================================================================== +# CSV emitters +# ========================================================================== +HERE = os.path.dirname(os.path.abspath(__file__)) + + +def write_morphological(): + path = os.path.join(HERE, "morphological.csv") + with open(path, "w", newline="") as fh: + w = csv.writer(fh) + w.writerow(["subsystem", "option", "role", "description", "proof_citation"]) + for sub in SUBSYSTEMS: + for o in sub.options: + w.writerow([sub.title, o.name, o.role, o.desc, o.proof]) + return path + + +def write_pugh(): + path = os.path.join(HERE, "pugh_scores.csv") + with open(path, "w", newline="") as fh: + w = csv.writer(fh) + w.writerow(["subsystem", "option", "role"] + CRITERIA + + ["weighted_default", "rank_default", "gate", "selected"]) + for sub in SUBSYSTEMS: + order = ranked(sub, WEIGHTS["default"], apply_gate=True) + rank_of = {id(o): i + 1 for i, o in enumerate(order)} + sel = selected(sub, WEIGHTS["default"]) + for o in sub.options: + w.writerow( + [sub.title, o.name, o.role] + + [o.scores[c] for c in CRITERIA] + + [weighted(o.scores, WEIGHTS["default"]), + rank_of[id(o)], + "DISQUALIFIED" if o.gate_fail else "ok", + "SELECTED" if o is sel else ""] + ) + return path + + +def write_sensitivity(): + path = os.path.join(HERE, "sensitivity.csv") + with open(path, "w", newline="") as fh: + w = csv.writer(fh) + w.writerow(["subsystem", "option", "role", + "w_default", "w_precision", "w_cost", + "win_default", "win_precision", "win_cost"]) + for sub in SUBSYSTEMS: + sel_d = selected(sub, WEIGHTS["default"]) + sel_p = selected(sub, WEIGHTS["precision"]) + sel_c = selected(sub, WEIGHTS["cost"]) + for o in sub.options: + w.writerow([ + sub.title, o.name, o.role, + weighted(o.scores, WEIGHTS["default"]), + weighted(o.scores, WEIGHTS["precision"]), + weighted(o.scores, WEIGHTS["cost"]), + "*" if o is sel_d else "", + "*" if o is sel_p else "", + "*" if o is sel_c else "", + ]) + return path + + +def summary() -> str: + lines = [] + for sub in SUBSYSTEMS: + sel_d = selected(sub, WEIGHTS["default"]) + sel_p = selected(sub, WEIGHTS["precision"]) + sel_c = selected(sub, WEIGHTS["cost"]) + changed = "STABLE" if (sel_d is sel_p is sel_c) else "CHANGES" + lines.append( + f"{sub.title:32s} default={sel_d.name:34s} " + f"prec={sel_p.name:34s} cost={sel_c.name:34s} [{changed}]" + ) + return "\n".join(lines) + + +if __name__ == "__main__": + p1 = write_morphological() + p2 = write_pugh() + p3 = write_sensitivity() + print("wrote:", p1, p2, p3, sep="\n ") + print("\nPer-subsystem winners under each weighting:") + print(summary()) diff --git a/design/mechanical/tradestudy/morphological.csv b/design/mechanical/tradestudy/morphological.csv new file mode 100644 index 0000000..bd3f7d7 --- /dev/null +++ b/design/mechanical/tradestudy/morphological.csv @@ -0,0 +1,24 @@ +subsystem,option,role,description,proof_citation +Optical tube assembly,MN78 f/8 (14 kg),baseline,"180 mm Mak-Newt, 1440 mm tube, 0.134 obstruction; doc-'selected'","stiffness: MN78 is the tube the FAIL is computed on (43.3"" vs 5"")" +Optical tube assembly,MN76 f/6 (9 kg),alternative,"178 mm Mak-Newt, 700 mm tube, 0.25 obstruction; lighter/shorter",torque: lighter tube => lower RA torque; stiffness: shorter CG lever +Optical tube assembly,MN86 f/6 (8-inch),alternative,"203 mm Mak-Newt; more aperture, heavier and longer tube",stiffness/torque: heaviest tube => worst mount loading +Optical tube assembly,APM-LZOS apo triplet,bold,"refractor, zero obstruction, premium optics; long/heavy/costly",thermal: glass soak vs metal-tube focus walk; precision: no obstruction +Optical tube assembly,ES-MN152 f/4.8,alternative,"152 mm Mak-Newt, in-production mass-market; cheap/light","torque/stiffness: lightest, in-production; lowest sourcing risk" +Mount topology,Counterweighted GEM,baseline,German equatorial + 12.5 kg counterweight on 457 mm shaft,balance: 12.5 kg balances 18 kg payload at r=288 mm (fit 1.59) +Mount topology,Counterweight-FREE GEM,bold,delete the shaft+weights; harmonic back-drive holds the imbalance,torque: RA SF 2.5 PASS; balance: deletes 15.4 kg + 29% RA inertia +Mount topology,Fork + field derotator,alternative,"symmetric fork, no meridian flip; derotator adds a rotation axis",precision: derotator injects a continuous field-rotation error term +Axis drive train,NEMA17 + 27:1 + harmonic,baseline,stepper + planetary + CSF harmonic (100:1 RA / 80:1 DEC),torque: PASS; encoder: on-axis ring moots upstream planetary PE +Axis drive train,Direct-to-harmonic,alternative,delete planetary; larger motor straight into the harmonic,torque: 0.45 Nm x100 = 45 Nm ~ 49.9 Nm need => bigger motor required +Axis drive train,Torque-motor direct drive,bold,"frameless torque motor on-axis, zero gear PE; needs on-axis encoder",encoder: only DD+ring reaches seeing-limited; power: holding-heat cost +Feedback / encoder chain,AMT103 motor + AS5600 axis,baseline,fine motor encoder + 12-bit on-axis chip (homing-grade),"encoder: 5.02"" RMS FAILS the 1.0"" target => DISQUALIFIED" +Feedback / encoder chain,On-axis high-res absolute ring,bold,"Renishaw RESA-class absolute ring, sub-arcsec LSB, on the axis","encoder: 0.54"" RMS => MEETS sub-arcsec (only option that does)" +Feedback / encoder chain,Hybrid (motor + on-axis absolute),alternative,motor encoder for velocity + on-axis absolute for position correction,encoder: on-axis correction closes the PE+flexure gap the motor can't see +Pier / foundation,Concrete Sonotube,baseline,12-inch x 36-inch concrete pier on a 36-inch embedment,"pier: governing SF 6.8 PASS, f_n 152 Hz (SF 15)" +Pier / foundation,Isolated pier-in-pier,bold,inner OTA pier isolated from the building slab / enclosure floor,pier: core stiffness unchanged; isolates roof-drive/footfall vibration +Pier / foundation,Steel-concrete hybrid,alternative,steel column on a concrete footing; faster to erect,pier: slender steel column => lower f_n; thermal bending of the column +Enclosure,Roll-off roof,baseline,"flat roll-off roof, garage-door-class drive",enclosure: drive SF 2.1 PASS; wind: uplift SF 0.19 => anchors mandatory +Enclosure,Clamshell dome,alternative,"rotating split-shell dome; better wind screen, worse flush",thermal: enclosed dome traps the DGX 14 K heat dump +Enclosure,Roll-off + active thermal,bold,roll-off base + insulation + forced ventilation + day pre-cooling,thermal: FAIL focus-walk remedy; power: exhausts DGX 14 K enclosure dump +Mount-head frame / housings,6061-T6 CNC plates,baseline,bolt-together CNC aluminium housings (E=68.9 GPa),stiffness: plates are NOT the governing compliance (bearings are) +Mount-head frame / housings,Steel weldment,alternative,"welded steel frame, ~3x modulus; heavier, weld distortion",stiffness: 3x E cuts only the small beam term; bearings still govern +Mount-head frame / housings,Cast housings,bold,"cast iron/Al monolithic housings; best damping, integral bearing seats",stiffness: integral large-span AC bearing seats attack the FAIL; foundry risk diff --git a/design/mechanical/tradestudy/pugh_scores.csv b/design/mechanical/tradestudy/pugh_scores.csv new file mode 100644 index 0000000..fa2d001 --- /dev/null +++ b/design/mechanical/tradestudy/pugh_scores.csv @@ -0,0 +1,24 @@ +subsystem,option,role,stiffness,precision,cost,buildability,thermal,sourcing,weighted_default,rank_default,gate,selected +Optical tube assembly,MN78 f/8 (14 kg),baseline,2,4,3,3,4,2,2.94,3,ok, +Optical tube assembly,MN76 f/6 (9 kg),alternative,4,3,3,4,3,2,3.21,2,ok, +Optical tube assembly,MN86 f/6 (8-inch),alternative,1,4,2,2,3,1,2.16,5,ok, +Optical tube assembly,APM-LZOS apo triplet,bold,2,5,1,3,3,2,2.76,4,ok, +Optical tube assembly,ES-MN152 f/4.8,alternative,4,3,5,4,3,5,3.99,1,ok,SELECTED +Mount topology,Counterweighted GEM,baseline,3,3,3,4,3,4,3.31,2,ok, +Mount topology,Counterweight-FREE GEM,bold,3,4,4,4,3,4,3.68,1,ok,SELECTED +Mount topology,Fork + field derotator,alternative,3,2,2,2,3,2,2.32,3,ok, +Axis drive train,NEMA17 + 27:1 + harmonic,baseline,3,2,4,4,3,4,3.24,1,ok,SELECTED +Axis drive train,Direct-to-harmonic,alternative,3,3,3,3,3,4,3.16,2,ok, +Axis drive train,Torque-motor direct drive,bold,3,5,1,2,2,2,2.73,3,ok, +Feedback / encoder chain,AMT103 motor + AS5600 axis,baseline,3,1,5,4,3,5,3.33,3,DISQUALIFIED, +Feedback / encoder chain,On-axis high-res absolute ring,bold,3,5,1,2,3,2,2.83,2,ok, +Feedback / encoder chain,Hybrid (motor + on-axis absolute),alternative,3,4,2,3,3,3,3.07,1,ok,SELECTED +Pier / foundation,Concrete Sonotube,baseline,4,3,5,4,4,5,4.09,1,ok,SELECTED +Pier / foundation,Isolated pier-in-pier,bold,4,4,3,3,4,4,3.7,2,ok, +Pier / foundation,Steel-concrete hybrid,alternative,3,3,3,3,2,3,2.9,3,ok, +Enclosure,Roll-off roof,baseline,3,3,5,5,3,5,3.92,1,ok,SELECTED +Enclosure,Clamshell dome,alternative,3,3,2,2,2,2,2.44,3,ok, +Enclosure,Roll-off + active thermal,bold,3,4,4,4,5,4,3.88,2,ok, +Mount-head frame / housings,6061-T6 CNC plates,baseline,3,3,4,4,4,4,3.56,1,ok,SELECTED +Mount-head frame / housings,Steel weldment,alternative,4,3,3,2,3,3,3.07,2,ok, +Mount-head frame / housings,Cast housings,bold,4,4,2,1,3,2,2.83,3,ok, diff --git a/design/mechanical/tradestudy/sensitivity.csv b/design/mechanical/tradestudy/sensitivity.csv new file mode 100644 index 0000000..54d60ad --- /dev/null +++ b/design/mechanical/tradestudy/sensitivity.csv @@ -0,0 +1,24 @@ +subsystem,option,role,w_default,w_precision,w_cost,win_default,win_precision,win_cost +Optical tube assembly,MN78 f/8 (14 kg),baseline,2.94,3.16,2.86,,, +Optical tube assembly,MN76 f/6 (9 kg),alternative,3.21,3.18,3.07,,, +Optical tube assembly,MN86 f/6 (8-inch),alternative,2.16,2.56,1.96,,, +Optical tube assembly,APM-LZOS apo triplet,bold,2.76,3.28,2.18,,, +Optical tube assembly,ES-MN152 f/4.8,alternative,3.99,3.74,4.37,*,*,* +Mount topology,Counterweighted GEM,baseline,3.31,3.22,3.35,,, +Mount topology,Counterweight-FREE GEM,bold,3.68,3.72,3.8,*,*,* +Mount topology,Fork + field derotator,alternative,2.32,2.28,2.2,,, +Axis drive train,NEMA17 + 27:1 + harmonic,baseline,3.24,2.92,3.6,*,,* +Axis drive train,Direct-to-harmonic,alternative,3.16,3.12,3.2,,, +Axis drive train,Torque-motor direct drive,bold,2.73,3.3,2.07,,*, +Feedback / encoder chain,AMT103 motor + AS5600 axis,baseline,3.33,2.74,4.05,,, +Feedback / encoder chain,On-axis high-res absolute ring,bold,2.83,3.38,2.15,,*, +Feedback / encoder chain,Hybrid (motor + on-axis absolute),alternative,3.07,3.3,2.75,*,,* +Pier / foundation,Concrete Sonotube,baseline,4.09,3.82,4.45,*,*,* +Pier / foundation,Isolated pier-in-pier,bold,3.7,3.8,3.5,,, +Pier / foundation,Steel-concrete hybrid,alternative,2.9,2.92,2.92,,, +Enclosure,Roll-off roof,baseline,3.92,3.64,4.4,*,,* +Enclosure,Clamshell dome,alternative,2.44,2.6,2.22,,, +Enclosure,Roll-off + active thermal,bold,3.88,3.88,3.96,,*, +Mount-head frame / housings,6061-T6 CNC plates,baseline,3.56,3.4,3.78,*,*,* +Mount-head frame / housings,Steel weldment,alternative,3.07,3.1,2.97,,, +Mount-head frame / housings,Cast housings,bold,2.83,3.18,2.37,,,