Compare commits
5
Commits
main
...
codex/karman-viv
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
516975ef64 | ||
|
|
e9213dd2cc | ||
|
|
7b81584458 | ||
|
|
d0bcefa60c | ||
|
|
33dcb422c6 |
@@ -0,0 +1,83 @@
|
||||
# Re_D = 50 Kármán inflow: paired pinball VIV / frozen PPO demo
|
||||
|
||||
Development experiment, frozen before results, 2026-10-31. No new production
|
||||
method, structural damping, force filter, policy training, or main merge.
|
||||
[Force-spike research index](research/moving_boundary_force_spikes.md).
|
||||
|
||||
## Plant and structure
|
||||
|
||||
Base `e9213dd2cc9cfed0e8487a3cf4ebdd94d23dc64d`; fixed-policy contract from
|
||||
`580397eaf83c85b233495791ecc51662a374a247`. Reuse `steady_pinball` config,
|
||||
coordinate map and exact held-force oscillator. D40, U=.005, nu=.004, MRT,
|
||||
2559 x 1022, FP32 unshifted double buffers. Regularized parabolic inlet,
|
||||
NEQ extrapolation outlet, bounce-back y walls (.5,1020.5); no BC changes.
|
||||
D20 coordinates map x'=2x, y'=.5+2(y-.5).
|
||||
Upstream cylinder (400,510.5), radius40, is static. Pinballs radius20 at
|
||||
(1200,510.5), (1252,540.5), (1252,480.5) share only transverse translation.
|
||||
Lab probes radius10 at (1600,590.5), (1600,510.5), (1600,430.5) stay fixed.
|
||||
|
||||
One shared `Translation`, travel ±D, max wall speed .025, existing clearance
|
||||
and per-step move guards unchanged. M2: m*=2 based on all three disks,
|
||||
M=7539.8223686, fn=.000025, K=.000186037660, C=zeta=0; release y=v=0.
|
||||
Each native step uses previous-step sum(CME_y+Wen_y), then exact oscillator
|
||||
update with held force. Wall speed is interval displacement y[n+1]-y[n].
|
||||
Cover/uncover impulses are logged, never added to the selected RHS. This
|
||||
estimator remains provisional; existing spike/convergence limitations remain.
|
||||
|
||||
## Same release state, two clocks
|
||||
|
||||
A static precursor retains the accepted Kármán sequence: upstream+probes,
|
||||
2,048,000-step warmup, 150 x 3,200-step no-pinball target samples, add static
|
||||
pinballs/sync, second 2,048,000-step warmup. Save both DDF buffers, flags,
|
||||
clock and last instantaneous force. Construct each moving arm fresh at the
|
||||
same zero-offset geometry, check flags, copy both buffers and clock, verify
|
||||
exact copy hashes. This one-time research initialization is not a moving
|
||||
checkpoint or FSI rollback. Initial policy observation and spin are zero.
|
||||
|
||||
Frozen model SHA256 `148240c1dcb8b11d8e5a0a9e991f6874c4e799c9744707bf0fb32a8efeb0468d`;
|
||||
normalizer `534fda818c030a56960e95d0f3ac1e350d887d03db7f80f1d6453195a502b7f0`.
|
||||
Only these explicit supplied assets are accepted. Policy is CPU deterministic.
|
||||
Native policy signals: packed CME (same `read_force` path), lab area-mean
|
||||
velocity, each averaged over SI3200. Convert to original D20 training units:
|
||||
forces x2, area-mean velocity x2 x78, then frozen force-first normalization
|
||||
and clipping. Fine-grid probe pixel footprint is not exactly identical;
|
||||
using the original78 cells avoids a hidden area-count normalization change.
|
||||
The moving plant, motion-induced force, lab-frame wake displacement and D40
|
||||
resolution remain out of training distribution. No transfer guarantee.
|
||||
|
||||
A=(8a+(0,-4,4))*.005; EMA alpha=.9^(1/4), reset0 each SI; omega=+A_eff/20.
|
||||
This preserves historical physical actuator timescale under dt/4.
|
||||
The zero arm substitutes a=(0,.5,-.5), so all spins are identically zero.
|
||||
Policy interval averaging does **not** average the native structural RHS.
|
||||
|
||||
## Bound, output, and interpretation
|
||||
|
||||
One serial pair, zero then controlled: 400SI=1,280,000 native steps=160D/U
|
||||
=32Tn per arm. No retuning/automatic extension. Stop on nonfinite state,
|
||||
no_donor, travel/speed/move rejection, or four-hour whole-pair wall cap.
|
||||
A failure stops the remaining run; retain valid rows and completed intervals.
|
||||
NaN trailing rows are unexecuted, not zero-valued observations.
|
||||
|
||||
Report first/last80D/U windows, mean offset separately from centered RMS,
|
||||
half-range, peak, frequency and drift; native versus SI-averaged force,
|
||||
CME/Wen/node/crossing terms and action/observation clipping. Last80D/U is
|
||||
the principal descriptive window, not an asserted settled window. If zero
|
||||
arm half-range <.1D there, classify weak response and do not claim vibration
|
||||
suppression. Report continuous ratios regardless. Cloaking uses retained
|
||||
canonical last30 lagged sensor DTW against this precursor's no-pinball target,
|
||||
with fixed-body D20 results contextual only. These are development criteria,
|
||||
not paper uncertainty, grid convergence, or proof of validated FSI.
|
||||
|
||||
Save native18-column rows (~184MB/arm), small SI signals, target, config,
|
||||
release two buffers (~188MiB), hashes and terminal reports outside checkout.
|
||||
No per-step field output. Expected new data <650MB, check actual resources.
|
||||
|
||||
```
|
||||
CUDA_VISIBLE_DEVICES=0 PYTHONPATH=src python research/karman_viv/run.py \
|
||||
--model /tmp/celeris-karman-viv-assets/model.zip \
|
||||
--norm /tmp/celeris-karman-viv-assets/norm.json --out /tmp/NEW_OUTPUT
|
||||
```
|
||||
|
||||
First `--smoke`: same D40 geometry, warmup32/target2x8/warmup32,
|
||||
4x8 response steps each arm; strictly plumbing only. CPU source checks and
|
||||
independent exact-tip review precede GPU. Preserve original DynamisLab files.
|
||||
@@ -0,0 +1,99 @@
|
||||
# Moving-boundary force spikes: evidence and source index
|
||||
|
||||
Updated 2026-10-31.
|
||||
|
||||
This note indexes supplied audited observations and primary-source extractions.
|
||||
It does not report a newly measured cure or implement an alternative method.
|
||||
Paper details below were supplied by the parent investigation; no new source
|
||||
retrieval or independent paper verification was performed for this note.
|
||||
|
||||
## Current implementation and interpretation
|
||||
|
||||
- Celeris M1 reconstructs a fresh node as `feq(rho_bar, u_wall) + donor NEQ`.
|
||||
- In exact arithmetic, the donor non-equilibrium component has zero mass and
|
||||
momentum when formed against the donor's own equilibrium moments.
|
||||
- Re-projecting that same reconstruction onto the same wall velocity therefore
|
||||
does not, by itself, change its mass or momentum or supply a cure for spikes.
|
||||
- Floating-point residuals should be measured separately from this algebraic fact.
|
||||
- Peng's alternative differs upstream: extrapolate PDFs from three donors, then
|
||||
replace selected moments. It is not simply another projection of current M1.
|
||||
- PPO interval-force observations and native per-step FSI right-hand-side forces
|
||||
are distinct signals; comparisons must identify which signal is being used.
|
||||
|
||||
## Audited run observations supplied to this note
|
||||
|
||||
- D40 revision: `e9213dd2cc9cfed0e8487a3cf4ebdd94d23dc64d`.
|
||||
- Window: first `97.303 D/U` of physical time.
|
||||
- Maximum displacement: `max |y|/D = 0.304893`.
|
||||
- Maximum native force-coefficient increment: `max |ΔCy| = 0.66688`.
|
||||
- D20 over the same physical-time window: `max |ΔCy| = 1.80735`.
|
||||
- The largest D40 event involved 30 nodes.
|
||||
- These are supplied audited observations, not measurements made for this note.
|
||||
- Time steps and trajectories differ between runs. This comparison establishes
|
||||
neither spatial convergence nor a unique cause for the force spikes.
|
||||
|
||||
Evidence locations supplied by the investigation:
|
||||
|
||||
- Horsea: `/tmp/celeris-results-pinball-resolution`.
|
||||
- Mac: `validation-evidence/pinball-force-diagnosis`.
|
||||
|
||||
These locations index run evidence, not local copies of the cited papers.
|
||||
|
||||
## Tao, Hu, and Guo (2016): time-step decoupling
|
||||
|
||||
Computers & Fluids **133**, 1–14. DOI:
|
||||
[10.1016/j.compfluid.2016.04.009](https://doi.org/10.1016/j.compfluid.2016.04.009).
|
||||
[Publisher abstract](https://www.sciencedirect.com/science/article/pii/S0045793016301116).
|
||||
|
||||
- Abstract-level support only: non-equilibrium refill reduces oscillations.
|
||||
- With local iteration, the compared refill methods give similar oscillation magnitudes.
|
||||
- Time-step decoupling reduces oscillations further in the reported sedimentation,
|
||||
Couette, and Poiseuille examples.
|
||||
- The exact decoupling schedule and time-step ratios have not been verified.
|
||||
- This abstract does not establish equivalence to an arbitrary force filter.
|
||||
- Do not implement a claimed reproduction until the actual methods are available.
|
||||
|
||||
## Marson et al. (2021): local iterations
|
||||
|
||||
Physical Review E **103**, 053308. DOI:
|
||||
[10.1103/PhysRevE.103.053308](https://doi.org/10.1103/PhysRevE.103.053308).
|
||||
[Open repository record](https://archive-ouverte.unige.ch/unige:151868).
|
||||
|
||||
- §VI, p. 18 discusses pressure waves associated with fresh nodes.
|
||||
- Appendix E.3, p. 25 and Fig. 18, p. 24 describe the local procedure.
|
||||
- Copy a local sublattice, perform local collision and streaming, write the fresh
|
||||
nodes, and repeat before the global collision and streaming operation.
|
||||
- One to three iterations performed best in the reported tests; this is not a
|
||||
universal iteration-count recommendation.
|
||||
- The reported smoothed torque-noise assessment is qualitative here.
|
||||
- Its thin-shell setting differs from the current moving-body configuration.
|
||||
|
||||
## Peng (2016): extrapolation and moment replacement
|
||||
|
||||
Computers & Mathematics with Applications **72**, 349–374. DOI:
|
||||
[10.1016/j.camwa.2015.08.027](https://doi.org/10.1016/j.camwa.2015.08.027).
|
||||
|
||||
- §5.1.2, Eq. (50), p. 368: extrapolate PDFs along an outward normal direction:
|
||||
`f_new(x) = 3 f(x + e) - 3 f(x + 2e) + f(x + 3e)`.
|
||||
- §5.1.3, Eq. (51): wall equilibrium plus donor non-equilibrium is the family
|
||||
containing the current M1 reconstruction.
|
||||
- §5.1.4, Eqs. (52)–(54): in MRT space, replace momentum with `j = rho0 u_wall`,
|
||||
preserve the other moments, then apply the inverse transform.
|
||||
- A compressible Celeris adaptation must choose the appropriate density;
|
||||
copying the paper's `rho0` blindly is not justified.
|
||||
- Three valid donors require new eligibility checks and fallback behavior beyond
|
||||
the current one-link shared-body clearance rule.
|
||||
- §§5.2–5.3 retain nonzero fluctuations in specific configurations; these results
|
||||
do not promise elimination of force spikes in the present problem.
|
||||
|
||||
## Bounded next investigation
|
||||
|
||||
1. Retain current M1 as the baseline and compare matched prescribed trajectories
|
||||
over matched physical-time windows.
|
||||
2. Record raw force RHS, node crossings, donor moments, mass and momentum changes,
|
||||
impulse, spectra, and trajectory response together.
|
||||
3. Evaluate local iterations, Peng-style reconstruction, and force filtering as
|
||||
separate interventions, with explicit definitions and diagnostics.
|
||||
4. Obtain the actual time-step-decoupling methods before implementing that method.
|
||||
|
||||
The current task indexes evidence only; it makes no alternative-method change.
|
||||
@@ -0,0 +1,150 @@
|
||||
# Uncontrolled steady pinball: development protocol
|
||||
|
||||
Frozen before results: 1280 x 512 D2Q9/FP32/MRT, unshifted double buffer;
|
||||
rho=1, mean U=.01, nu=.004, D=20, Re_D=50. Centres are (600,255.5),
|
||||
(626,270.5), (626,240.5), radius10. No upstream cylinder. Passive probes stay
|
||||
at (800,295.5), (800,255.5), (800,215.5), radius5. Every omega is zero.
|
||||
Parabolic inlet, regularized NEQ damping1; y bounce-back planes .5/510.5;
|
||||
NEQ-extrapolated outlet with backflow clamp (the configured blend is inactive in
|
||||
this MRT route). Initial fluid is
|
||||
parabolic equilibrium. Fixed warmup is 4 NX/U=512000 steps.
|
||||
|
||||
Geometry and fluid parameters follow DynamisLab 609116ec26f19766f5e9ce2b0df427d4e24faae8,
|
||||
`legacy_test/core/legacy_env_builder.py:build_steady_cloak` and the retained
|
||||
`config_lbm_pinball_legacy_re50d_u001.json`. This is a modern Celeris reconstruction,
|
||||
not a bitwise Legacy reproduction: Legacy uses shifted incompressible MRT, a
|
||||
different curved interpolation and prescribed parabolic outlet velocity. Current
|
||||
Celeris uses standard MRT, two-branch BFL and extrapolated outlet velocity.
|
||||
The fixed-hold control uses the SAME modern settings as the free case.
|
||||
|
||||
## Shared translation and ownership
|
||||
|
||||
`Translation(..., shared=True)` is explicitly transverse only. Reusing one
|
||||
envelope INSTANCE declares common motion; equal but separate envelopes are not
|
||||
linked. Before initialization and every positive Simulation.run, group members
|
||||
must have identical applied offsets, effective target offsets and interval wall
|
||||
velocities. Stage each member with set_body, then call run(1) once. An incomplete
|
||||
or inconsistent target group is rejected before device uploads and can be fixed.
|
||||
Existing pending-target direct-stepper restrictions remain.
|
||||
|
||||
Only members of that group may have overlapping whole swept bands. Their fixed
|
||||
surface clearance must exceed4 lattice cells. This conservative bound exceeds
|
||||
2 sqrt(2)+MAX_STEP = 3.329: M1 changed cells lie in old/new disks and read one-link
|
||||
neighbors; M2 reads at most two links outside its new disk. Old/new relative
|
||||
displacement is at most .5. Thus reads cannot reach another member's old/new
|
||||
solid or M1 write region. M1 writes are disjoint. M2 writes opposite population
|
||||
slots inside its own new disk, also disjoint. Separate first_slot ranges still
|
||||
allocate each body's logical band/debug storage. Shared band cells alone do not
|
||||
write anything: M1 returns unless membership changes; M2 returns unless a cut
|
||||
link exists. Unrelated moving/static body and probe overlaps still reject.
|
||||
|
||||
This is a sufficient restricted-group condition, not support for near-contact
|
||||
or independent motion. The r4, distance8.3, dy=.5 counterexample remains rejected.
|
||||
CPU tests enumerate candidate donors/write footprints over subcell phases;
|
||||
GPU tests compare fixed vs hold and member-order permutations through crossings
|
||||
at wall speed .01 over the full +/-D envelope (8000 steps). The initial .5-speed
|
||||
fixture failed with NaNs; a single-circle control also failed at step69. That
|
||||
Mach~.866 fixture was unsuitable as a low-Mach numerical gate. Its failure is
|
||||
retained; the geometric MAX_STEP proof is unchanged, not a stability guarantee.
|
||||
CUDA kernels and arithmetic are unchanged.
|
||||
|
||||
## New structural parameters (not inherited from the paper)
|
||||
|
||||
One transverse state y,v; total assembly M=10 rho (3 pi D^2/4)=9424.77796,
|
||||
fn=U/(5D)=.0001, Tn=10000, zeta=.01, K=M(2 pi fn)^2,
|
||||
C=2 zeta M(2 pi fn). Initial y=v=0, no seed or later kick. Force is the sum
|
||||
of the three instantaneous CME_y+Wen_y values from the previous fluid step;
|
||||
node impulses are logged separately and never added. The damped oscillator is
|
||||
integrated exactly for that piecewise-constant force over dt=1. Wall velocity
|
||||
is y[n+1]-y[n], not the endpoint oscillator velocity. No filtering or retuning.
|
||||
|
||||
Observe600000 steps=60Tn=300 D/U after warmup, in each of free and fixed-hold
|
||||
cases. Final20Tn is a descriptive comparison window; ten-Tn blocks expose drift.
|
||||
No settled/periodic claim follows merely from reaching the cap. Stop on |y|>D,
|
||||
nonfinite values, no_donor, rejected displacement or speed>.05. Report any quiet
|
||||
response honestly. No automatic extension. --smoke uses32 warmup+32 steps only.
|
||||
|
||||
Native-step rows retain y,v, each body's CME/Wen/node terms and refill counters;
|
||||
each600000x15 FP64 file is72MB. No full-field output or policy training. Compile
|
||||
and run on one device serially, keep original outputs and dirty trees untouched.
|
||||
This stage makes no claim of physical grid convergence or vibration suppression.
|
||||
|
||||
Run from checkout with PYTHONPATH=src and the existing Python environment:
|
||||
`python research/steady_pinball/run.py --smoke --out <new-external-directory>`
|
||||
then separately `--out <new-free-directory>` and `--fixed --out <new-fixed-directory>`.
|
||||
|
||||
## Zero-damping development follow-up (predeclared 2026-10-31)
|
||||
|
||||
The original d0bcefa run and its zeta=.01 results remain historical evidence.
|
||||
The current runner exposes three fixed `--case` choices, all with C=0 as requested:
|
||||
|
||||
| Case | M / (rho 3 pi D^2/4) | fn | M | K | Purpose |
|
||||
|---|---:|---:|---:|---:|---|
|
||||
| Z0 | 10 | .0001 | 9424.77796 | .00372075320 | Remove damping only |
|
||||
| M2 | 2 | .0001 | 1884.95559 | .000744150640 | Reduce mass and stiffness together at fixed fn |
|
||||
| R2 | 2 | .00006 | 1884.95559 | .000267894231 | Change stiffness only relative to M2 |
|
||||
|
||||
R2 deliberately places fn at the retained fixed-body lift peak, f=.00006
|
||||
(St=.12 on mean U). This is an informed development choice, not independent
|
||||
physical validation or a promise of lock-in. All geometries, fluid settings,
|
||||
512000-step fixed warmup, y0=v0=0, force time level and unfiltered RHS are unchanged.
|
||||
No run result may select a replacement parameter or an automatic extension.
|
||||
|
||||
Each case has a 600000-step cap, the same 300 D/U physical duration. This equals
|
||||
60 natural periods for Z0/M2 and 36 for R2. Compare the common final200000 native
|
||||
steps, and six100000-step blocks, retaining actual natural/response frequencies
|
||||
and ongoing drift. Do not call those blocks ten R2 periods. No settled-VIV claim
|
||||
follows from a larger displacement. Report all three outcomes, including quiet
|
||||
responses or stopped cases, alongside the existing fixed and damped-free records.
|
||||
|
||||
Unchanged stops: |y|>D, invalid move/speed, nonfinite values or no_donor. A 1800s
|
||||
per-case wall cap and GPU headroom check bound resource use. Preserve partial rows;
|
||||
do not add damping, clip trajectories, filter forces, or relax any stop. Each
|
||||
case adds72MB of scalar history, no full fields. The separate Legacy/modern
|
||||
baseline diagnosis does not change this FSI protocol.
|
||||
|
||||
## Matched D40 resolution comparison (predeclared 2026-10-31)
|
||||
|
||||
The first finer case is M2, with exactly the same mass ratio2, reduced velocity5,
|
||||
zero structural damping, initial rest state and physical geometry as the retained
|
||||
D20 M2. Targeting a development response of0.5--1D does not imply this case will
|
||||
reach it. Do not change the structure within this comparison or discard a smaller
|
||||
response. D60 is a supported scaling choice, not an automatically scheduled run.
|
||||
|
||||
For s=D/20 use dx'=dx/s, dt'=dt/s^2, U'=.01/s, nu'=.004, rho'=1,
|
||||
fn'=fn/s^2, M'=s^2 M and K'=K/s^2. The same physical force per unit span
|
||||
has F'_lat=F_lat/s. For D40 this gives U=.005, fn=.000025,
|
||||
M=7539.82237, K=.000186037660, Re_D=50, and Tn=40000 steps.
|
||||
The y walls are at .5 and ny-1.5, while x inlet/outlet source nodes are at0
|
||||
and nx-1. Therefore map x'=s*x and y'=.5+s*(y-.5), with
|
||||
nx'=s*(1280-1)+1, ny'=s*(512-2)+2. D40 uses2559x1022 and centres
|
||||
(1200,510.5),(1252,540.5),(1252,480.5); every radius, probe and envelope scales
|
||||
with D. The parabolic profile retains the same normalized wall coordinate.
|
||||
This preserves the existing ghost-source boundary convention, without asserting
|
||||
that it is an exact continuum open-boundary location.
|
||||
|
||||
Warmup=2048000 and response=2400000 native steps at D40: the SAME256 and300
|
||||
D/U durations as D20, including60 natural periods after release. Compare the
|
||||
six50-D/U blocks and the common final100-D/U window. Report max absolute y/D,
|
||||
half-range, RMS, frequency and drift; peak-to-peak is not amplitude. No settling
|
||||
claim follows from completion. Keep raw individual CME/Wen/node terms and all
|
||||
crossing/fallback counters. Normalize net force by .5*rho*U^2*D in each case.
|
||||
Report native crossing/non-crossing force increments separately, plus D40
|
||||
four-step block means aligned with D20 intervals (diagnostic only, never replace
|
||||
the raw spikes). Report final-window high-frequency variance above f*D/U=1,
|
||||
using demeaned FFT Parseval weights, with the same physical cutoff for both cases.
|
||||
Different motion histories confound spike-size comparisons; this is simultaneous
|
||||
space/time/Mach refinement, not proof of a unique coarse-grid cause.
|
||||
|
||||
Only one full D40 M2 is initially scheduled, after CPU checks and a32+32-step
|
||||
smoke. Stop on the unchanged geometry/speed/nonfinite/no_donor checks or a7200s
|
||||
whole-case wall cap; save completed rows and status. The velocity cap scales to
|
||||
.025 so its physical bound is unchanged. No force filtering, trajectory clipping,
|
||||
automatic parameter search or extension. Scalar history is288MB; no full fields
|
||||
or DDF snapshots are written. Two FP32 population buffers require about188MiB,
|
||||
before flags, moving tables and context overhead; measure actual peak usage in
|
||||
the smoke and full run. Keep all D20 outcomes. No production source changes.
|
||||
|
||||
Commands (one GPU and serial compilation, existing environment):
|
||||
`python research/steady_pinball/run.py --case M2 --D 40 --smoke --out <new-smoke-dir>`
|
||||
then `python research/steady_pinball/run.py --case M2 --D 40 --wall-seconds 7200 --out <new-full-dir>`.
|
||||
@@ -0,0 +1,292 @@
|
||||
"""One paired zero/PPO Karman pinball VIV experiment; see docs/karman_viv.md.
|
||||
|
||||
Research-only adapter: static precursor -> two fresh, identical moving plants.
|
||||
No moving checkpoint/rollback, policy retraining, or force filtering.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
from research.steady_pinball.run import Structure, config, scaled_points, CENTRES, PROBES, COLUMNS
|
||||
|
||||
BIAS = np.array([0., -4., 4.])
|
||||
MODEL_SHA = '148240c1dcb8b11d8e5a0a9e991f6874c4e799c9744707bf0fb32a8efeb0468d'
|
||||
NORM_SHA = '534fda818c030a56960e95d0f3ac1e350d887d03db7f80f1d6453195a502b7f0'
|
||||
STEP_COLUMNS = (*COLUMNS, 'omega_F', 'omega_U', 'omega_L')
|
||||
|
||||
|
||||
def sha(path):
|
||||
h = hashlib.sha256()
|
||||
with open(path, 'rb') as f:
|
||||
for block in iter(lambda: f.read(1024*1024), b''):
|
||||
h.update(block)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def array_sha(a):
|
||||
return hashlib.sha256(memoryview(np.ascontiguousarray(a)).cast('B')).hexdigest()
|
||||
|
||||
|
||||
def observation(sensor_mean, force_mean, scale, norm):
|
||||
"""Fine-grid area means/CME -> trained D20 sensor sums/forces -> frozen norm."""
|
||||
sensors = np.asarray(sensor_mean, np.float64)*scale*78.
|
||||
forces = np.asarray(force_mean, np.float64)*scale
|
||||
raw = np.asarray([*sensors, *forces], np.float32)
|
||||
preclip = np.hstack((raw[6:]/norm['force_norm_fact'],
|
||||
(raw[:6]-np.asarray(norm['sens_deviation'], np.float32)) /
|
||||
np.asarray(norm['sens_norm_fact'], np.float32)))
|
||||
if not np.isfinite(preclip).all():
|
||||
raise RuntimeError('nonfinite policy observation')
|
||||
return raw, np.clip(preclip, -1, 1).astype(np.float32), int((np.abs(preclip)>1).sum())
|
||||
|
||||
|
||||
def wall_target(action, scale):
|
||||
return (8.*np.asarray(action)+BIAS)*(.01/scale)
|
||||
|
||||
|
||||
def ema(eff, target, scale):
|
||||
alpha = .9**(1./scale**2)
|
||||
return alpha*eff + (1.-alpha)*target
|
||||
|
||||
|
||||
def load_policy(model):
|
||||
# Same inference spaces as legacy_test.core.model_loader, without importing it.
|
||||
import gymnasium as gym
|
||||
from stable_baselines3 import PPO
|
||||
class InferenceEnv(gym.Env):
|
||||
observation_space = gym.spaces.Box(-1., 1., (12,), np.float32)
|
||||
action_space = gym.spaces.Box(-1., 1., (3,), np.float32)
|
||||
def reset(self, *, seed=None, options=None):
|
||||
super().reset(seed=seed)
|
||||
return np.zeros(12, np.float32), {}
|
||||
def step(self, action):
|
||||
return np.zeros(12, np.float32), 0., False, False, {}
|
||||
return PPO.load(str(model), env=InferenceEnv(), device='cpu')
|
||||
|
||||
|
||||
def add_scene(sim, scale, moving=False, pinball=True):
|
||||
from CelerisLab.body.motion import Translation
|
||||
sim.add_body('circle', scaled_points(((200., 255.5),), scale)[0], 20.*scale)
|
||||
probes = [sim.add_body('sensor', c, 5.*scale) for c in scaled_points(PROBES, scale)]
|
||||
bodies = []
|
||||
if pinball:
|
||||
envelope = Translation((0., 0., -20.*scale, 20.*scale), .05/scale, shared=True) if moving else None
|
||||
bodies = [sim.add_body('circle', c, 10.*scale, translation=envelope)
|
||||
for c in scaled_points(CENTRES, scale)]
|
||||
return probes, bodies
|
||||
|
||||
|
||||
def capture_seed(sim):
|
||||
"""Capture both FP32 buffers at fixed geometry; never a moving-state checkpoint."""
|
||||
import pycuda.driver as cuda
|
||||
with sim.ctx.activate():
|
||||
sim.stream.synchronize()
|
||||
buffers = [np.empty_like(sim.field.ddf) for _ in range(2)]
|
||||
for dst, src in zip(buffers, (sim.field.ddf_gpu, sim.field.temp_gpu)):
|
||||
cuda.memcpy_dtoh(dst, src)
|
||||
return (*buffers, sim.get_flags(), sim.stepper.step_count)
|
||||
|
||||
|
||||
def install_seed(sim, seed):
|
||||
"""Only a new, zero-offset instance may receive the static release state."""
|
||||
import pycuda.driver as cuda
|
||||
current, spare, flags, clock = seed
|
||||
if sim.stepper.step_count != 0 or any(a.shape != sim.field.ddf.shape or a.dtype != sim.field.ddf.dtype
|
||||
for a in (current, spare)):
|
||||
raise ValueError('release transfer requires a fresh matching field')
|
||||
if not np.array_equal(sim.get_flags(), flags):
|
||||
raise ValueError('static/moving release flags differ')
|
||||
for obj in sim.bodies.objects:
|
||||
if obj.motion is not None and (obj.motion.applied != (0., 0.) or obj.motion.pending is not None):
|
||||
raise ValueError('release transfer requires zero geometry and no target')
|
||||
with sim.ctx.activate():
|
||||
sim.stream.synchronize()
|
||||
cuda.memcpy_htod(sim.field.ddf_gpu, current)
|
||||
cuda.memcpy_htod(sim.field.temp_gpu, spare)
|
||||
sim.stepper._step_count = clock # one-time research initialization, not restore()
|
||||
sim.field.invalidate_host_ddf_cache()
|
||||
copied = capture_seed(sim)
|
||||
if copied[3] != clock or any(not np.array_equal(a, b, equal_nan=True) for a, b in zip(copied[:3], seed[:3])):
|
||||
raise RuntimeError('release transfer did not preserve both buffers/flags')
|
||||
return dict(current=array_sha(copied[0]), spare=array_sha(copied[1]), flags=array_sha(copied[2]), clock=copied[3])
|
||||
|
||||
|
||||
def check_time(start, cap):
|
||||
if time.monotonic()-start > cap:
|
||||
raise TimeoutError('paired experiment wall cap')
|
||||
|
||||
|
||||
def warmup(sim, count, start, cap):
|
||||
while count:
|
||||
check_time(start, cap)
|
||||
n = min(count, 10000)
|
||||
sim.run(n)
|
||||
count -= n
|
||||
|
||||
|
||||
def precursor(cfgpath, scale, warm, si, targets, out, start, cap):
|
||||
from CelerisLab import Simulation
|
||||
with Simulation(str(cfgpath)) as sim:
|
||||
probes, _ = add_scene(sim, scale, pinball=False)
|
||||
sim.initialize()
|
||||
warmup(sim, warm, start, cap)
|
||||
target = []
|
||||
for _ in range(targets):
|
||||
check_time(start, cap)
|
||||
sim.run(si)
|
||||
target.append([v*scale*78. for p in probes for v in sim.read_sensor(p)[:2]])
|
||||
np.save(out/'target.npy', np.asarray(target, np.float32))
|
||||
first = sim.bodies.count
|
||||
for c in scaled_points(CENTRES, scale):
|
||||
sim.add_body('circle', c, 10.*scale)
|
||||
sim.sync_bodies()
|
||||
bodies = list(range(first, first+3))
|
||||
warmup(sim, warm-1, start, cap)
|
||||
sim.run(1)
|
||||
force = sum(float(sim.read_force(p)[1]) for p in bodies)
|
||||
seed = capture_seed(sim)
|
||||
np.savez(out/'release.npz', current=seed[0], spare=seed[1], flags=seed[2], clock=seed[3], force=force)
|
||||
print(json.dumps(dict(event='precursor_complete', clock=seed[3], force=force,
|
||||
elapsed_s=time.monotonic()-start)), flush=True)
|
||||
return seed, force
|
||||
|
||||
|
||||
def run_arm(name, cfgpath, scale, si, intervals, seed, initial_force, model, norm, out, start, cap):
|
||||
from CelerisLab import Simulation
|
||||
from CelerisLab.body.motion import MovingTables
|
||||
cols = {k:i for i, k in enumerate(MovingTables.DIAG)}
|
||||
diameter = 20.*scale
|
||||
structure = Structure(2., 1.e-4/scale**2, 0., diameter)
|
||||
arm = out/name
|
||||
arm.mkdir()
|
||||
rows = np.lib.format.open_memmap(arm/'steps.npy', mode='w+', dtype=np.float64,
|
||||
shape=(si*intervals, len(STEP_COLUMNS)))
|
||||
rows[:] = np.nan
|
||||
signals = {k:np.full((intervals, width), np.nan, np.float32) for k, width in
|
||||
[('sensors',6), ('forces',6), ('actions',3), ('observations',12)]}
|
||||
completed = completed_intervals = clipped = 0
|
||||
status, transfer = 'running', None
|
||||
force = initial_force
|
||||
obs = np.zeros(12, np.float32)
|
||||
arm_start = time.monotonic()
|
||||
try:
|
||||
with Simulation(str(cfgpath)) as sim:
|
||||
probes, bodies = add_scene(sim, scale, moving=True)
|
||||
sim.initialize()
|
||||
transfer = install_seed(sim, seed)
|
||||
for i in range(intervals):
|
||||
action = -BIAS/8 if name == 'zero' else np.asarray(model.predict(obs, deterministic=True)[0], np.float32)
|
||||
if action.shape != (3,) or not np.isfinite(action).all() or (np.abs(action)>1).any():
|
||||
raise RuntimeError('invalid policy action')
|
||||
target, eff = wall_target(action, scale), np.zeros(3)
|
||||
sensor_sum, force_sum = np.zeros(6), np.zeros(6)
|
||||
for k in range(si):
|
||||
if k % 1000 == 0:
|
||||
check_time(start, cap)
|
||||
y1, v1 = structure.candidate(force)
|
||||
if not np.isfinite([y1,v1]).all() or abs(y1)>diameter:
|
||||
raise RuntimeError('nonfinite structure or |y| > D; no retuning')
|
||||
eff = ema(eff, target, scale)
|
||||
omega = eff/(diameter/2)
|
||||
for p, w in zip(bodies, omega):
|
||||
sim.set_body(p, omega=float(w), offset=(0., y1), vy=y1-structure.y)
|
||||
sim.run(1, zero_obs=True, sync_obs=True)
|
||||
d = sim.moving_diagnostics()
|
||||
cme, wen = d[:,cols['cme_y']], d[:,cols['wen_y']]
|
||||
node = d[:,cols['cover_jy']]-d[:,cols['uncover_jy']]
|
||||
row = (completed+1,y1,v1,*cme,*wen,*node,
|
||||
d[:,cols['eq_only']].sum(),d[:,cols['no_donor']].sum(),d[:,cols['crossings']].sum(),*omega)
|
||||
if not np.isfinite(row).all() or row[13] != 0:
|
||||
raise RuntimeError('nonfinite moving diagnostic or no_donor')
|
||||
f = np.array([v for p in bodies for v in sim.read_force(p)[:2]])
|
||||
s = np.array([v for p in probes for v in sim.read_sensor(p)[:2]])
|
||||
if not np.isfinite([f,s]).all():
|
||||
raise RuntimeError('nonfinite force/sensor observation')
|
||||
# Policy sees packed CME as in fixed-body training; structure sees CME+Wen.
|
||||
force_sum += f
|
||||
sensor_sum += s
|
||||
force = float(cme.sum(dtype=np.float64)+wen.sum(dtype=np.float64))
|
||||
rows[completed] = row
|
||||
completed += 1
|
||||
structure.y, structure.v = y1,v1
|
||||
raw, obs, nclip = observation(sensor_sum/si, force_sum/si, scale, norm)
|
||||
clipped += nclip
|
||||
for key, val in [('sensors',raw[:6]),('forces',raw[6:]),('actions',action),('observations',obs)]:
|
||||
signals[key][i] = val
|
||||
completed_intervals = i+1
|
||||
if (i+1)%10 == 0 or i+1 == intervals:
|
||||
rows.flush()
|
||||
np.savez(arm/'signals.npz', **signals)
|
||||
print(json.dumps(dict(event='progress', arm=name, interval=i+1, step=completed,
|
||||
tU_D=completed*.01/scale/diameter, y_D=y1/diameter,
|
||||
elapsed_s=time.monotonic()-arm_start)), flush=True)
|
||||
status = 'duration_completed_not_qualified'
|
||||
except BaseException as exc:
|
||||
status = f'error: {type(exc).__name__}: {exc}'
|
||||
raise
|
||||
finally:
|
||||
rows.flush()
|
||||
np.savez(arm/'signals.npz', **signals)
|
||||
report = dict(status=status, completed_steps=completed, completed_intervals=completed_intervals,
|
||||
elapsed_s=time.monotonic()-arm_start, release_transfer=transfer, clipped_observation_channels=clipped,
|
||||
columns=STEP_COLUMNS, M=structure.mass,K=structure.stiffness,fn=structure.frequency,damping=0.,
|
||||
max_abs_y_D=float(np.max(np.abs(rows[:completed,1]))/diameter) if completed else None)
|
||||
(arm/'report.json').write_text(json.dumps(report,indent=2)+'\n')
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument('--model', type=Path, required=True)
|
||||
ap.add_argument('--norm', type=Path, required=True)
|
||||
ap.add_argument('--out', type=Path, required=True)
|
||||
ap.add_argument('--smoke', action='store_true', help='short plumbing only; no physical conclusion')
|
||||
ap.add_argument('--wall-seconds', type=float, default=14400.)
|
||||
args = ap.parse_args()
|
||||
if not np.isfinite(args.wall_seconds) or args.wall_seconds<=0:
|
||||
ap.error('positive finite wall cap required')
|
||||
if sha(args.model)!=MODEL_SHA or sha(args.norm)!=NORM_SHA:
|
||||
ap.error('model/normalizer must match frozen provenance')
|
||||
out = args.out.resolve()
|
||||
if out == ROOT or ROOT in out.parents:
|
||||
ap.error('output must be outside checkout')
|
||||
out.mkdir(parents=True, exist_ok=False)
|
||||
norm = json.loads(args.norm.read_text())
|
||||
model = load_policy(args.model)
|
||||
scale = 2.
|
||||
warm, si, targets, intervals = (32,8,2,4) if args.smoke else (2048000,3200,150,400)
|
||||
cfgpath = out/'config.json'
|
||||
cfgpath.write_text(json.dumps(config(40),indent=2)+'\n')
|
||||
receipt = dict(source=subprocess.check_output(['git','-C',str(ROOT),'rev-parse','HEAD'],text=True).strip(),
|
||||
model_sha256=sha(args.model),norm_sha256=sha(args.norm),smoke=args.smoke,D=40,U=.005,nu=.004,
|
||||
warmup_each=warm,target_intervals=targets,SI=si,response_intervals=intervals,
|
||||
response_steps=si*intervals,response_tU_D=si*intervals*.005/40,
|
||||
rhs='previous native step sum CME_y+Wen_y; policy uses interval packed CME',
|
||||
probe_frame='lab-fixed; fine area mean * 2 * trained 78 cells',
|
||||
ema_native_alpha=.9**.25, structural_damping=0., columns=STEP_COLUMNS)
|
||||
(out/'case.json').write_text(json.dumps(receipt,indent=2)+'\n')
|
||||
start = time.monotonic()
|
||||
status = 'running'
|
||||
try:
|
||||
seed, force = precursor(cfgpath,scale,warm,si,targets,out,start,args.wall_seconds)
|
||||
for name in ('zero','controlled'):
|
||||
run_arm(name,cfgpath,scale,si,intervals,seed,force,model,norm,out,start,args.wall_seconds)
|
||||
status = 'smoke_completed' if args.smoke else 'pair_completed_not_qualified'
|
||||
except BaseException as exc:
|
||||
status = f'error: {type(exc).__name__}: {exc}'
|
||||
raise
|
||||
finally:
|
||||
(out/'terminal.json').write_text(json.dumps(dict(status=status,elapsed_s=time.monotonic()-start),indent=2)+'\n')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"_doc": "Legacy geometry Re_D=50, U0=0.01, SI=800 baseline (kar_legacy_re50d_u001)",
|
||||
"grid": {
|
||||
"lattice_model": "D2Q9",
|
||||
"nx": 1280,
|
||||
"ny": 512,
|
||||
"nz": 1
|
||||
},
|
||||
"physics": {
|
||||
"data_type": "FP32",
|
||||
"viscosity": 0.004,
|
||||
"velocity": 0.01,
|
||||
"rho": 1.0
|
||||
},
|
||||
"method": {
|
||||
"collision": "MRT",
|
||||
"streaming": "double_buffer",
|
||||
"store_precision": "FP32",
|
||||
"ddf_shifting": false,
|
||||
"les": {
|
||||
"enabled": false,
|
||||
"cs": 0.16,
|
||||
"closed_form": true
|
||||
},
|
||||
"trt": {
|
||||
"magic_param": 0.1875
|
||||
},
|
||||
"inlet": {
|
||||
"profile": "parabolic",
|
||||
"scheme": "regularized",
|
||||
"regularized_neq_damp": 1.0
|
||||
},
|
||||
"outlet": {
|
||||
"mode": "neq_extrap",
|
||||
"backflow_clamp": true,
|
||||
"blend_alpha": 0.7,
|
||||
"srt_neq_damp": 0.5
|
||||
},
|
||||
"y_wall_bc": "bounce_back",
|
||||
"omega_guard": {
|
||||
"min": 0.01,
|
||||
"max": 1.99
|
||||
}
|
||||
},
|
||||
"cuda": {
|
||||
"threads_per_block": 256,
|
||||
"compute_capability": "auto"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Uncontrolled paper-geometry steady Re_D=50, one transverse assembly DOF.
|
||||
|
||||
Three predeclared zero-damping structures; --smoke uses only 32 + 32 steps.
|
||||
See docs/steady_pinball_fiv.md for historical solver differences and new structure.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
D, U, NU = 20., .01, .004
|
||||
CENTRES = ((600., 255.5), (626., 270.5), (626., 240.5))
|
||||
PROBES = ((800., 295.5), (800., 255.5), (800., 215.5))
|
||||
WARMUP, STEPS, PERIOD = 512000, 600000, 10000
|
||||
STRUCTURES = {'Z0': (10., 1.e-4), 'M2': (2., 1.e-4), 'R2': (2., 6.e-5)}
|
||||
COLUMNS = ('step', 'y', 'v', 'cme_F', 'cme_U', 'cme_L', 'wen_F', 'wen_U', 'wen_L',
|
||||
'node_F', 'node_U', 'node_L', 'eq_only', 'no_donor', 'crossings')
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def config(diameter=D):
|
||||
"""Diffusive refinement preserves the inlet/outlet and half-grid y walls."""
|
||||
scale = diameter / D
|
||||
if diameter not in (20, 40, 60):
|
||||
raise ValueError('diameter must be 20, 40 or 60')
|
||||
cfg = json.loads(Path(__file__).with_name('config.json').read_text())
|
||||
cfg['grid']['nx'] = round((cfg['grid']['nx'] - 1) * scale + 1)
|
||||
cfg['grid']['ny'] = round((cfg['grid']['ny'] - 2) * scale + 2)
|
||||
cfg['physics']['velocity'] = U / scale
|
||||
return cfg
|
||||
|
||||
|
||||
def scaled_points(points, scale):
|
||||
return tuple((scale*x, .5 + scale*(y-.5)) for x, y in points)
|
||||
|
||||
|
||||
class Structure:
|
||||
"""Exact damped oscillator update for a force held over dt=1; no force filter."""
|
||||
def __init__(self, mass_ratio=10., frequency=1.e-4, zeta=0., diameter=D):
|
||||
if not (math.isfinite(mass_ratio) and mass_ratio > 0. and
|
||||
math.isfinite(frequency) and frequency > 0. and
|
||||
math.isfinite(zeta) and 0. <= zeta < 1.):
|
||||
raise ValueError('positive finite mass ratio/frequency and 0 <= zeta < 1 required')
|
||||
self.mass_ratio, self.frequency = mass_ratio, frequency
|
||||
self.mass = mass_ratio * 3. * math.pi * diameter * diameter / 4.
|
||||
self.omega = 2. * math.pi * frequency
|
||||
self.zeta = zeta
|
||||
self.stiffness = self.mass * self.omega**2
|
||||
self.damping = 2. * self.zeta * self.mass * self.omega
|
||||
self.y = self.v = 0.
|
||||
a = self.zeta * self.omega
|
||||
wd = self.omega * math.sqrt(1. - self.zeta**2)
|
||||
self.ec = math.exp(-a) * math.cos(wd)
|
||||
self.es = math.exp(-a) * math.sin(wd) / wd
|
||||
self.a = a
|
||||
|
||||
def candidate(self, force):
|
||||
equilibrium = force / self.stiffness
|
||||
z = self.y - equilibrium
|
||||
return (equilibrium + (self.ec + self.a*self.es)*z + self.es*self.v,
|
||||
-self.omega**2*self.es*z + (self.ec - self.a*self.es)*self.v)
|
||||
|
||||
|
||||
def summarize(rows, diameter=D, block_steps=10*PERIOD):
|
||||
windows = []
|
||||
for start in range(0, len(rows), block_steps):
|
||||
block = rows[start:start + block_steps]
|
||||
fy = block[:, 3:9].sum(axis=1)
|
||||
windows.append(dict(start_step=int(block[0, 0]), end_step=int(block[-1, 0]),
|
||||
y_mean_D=float(block[:, 1].mean()/diameter),
|
||||
y_rms_D=float(np.sqrt(np.mean(block[:, 1]**2))/diameter),
|
||||
y_peak_D=float(np.abs(block[:, 1]).max()/diameter),
|
||||
fy_mean=float(fy.mean()), fy_rms=float(np.sqrt(np.mean(fy**2))),
|
||||
mean_power=float(np.mean(fy*block[:, 2]))))
|
||||
return windows
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument('--out', required=True, type=Path)
|
||||
ap.add_argument('--fixed', action='store_true', help='same modern moving-path geometry held fixed')
|
||||
ap.add_argument('--smoke', action='store_true', help='32+32 steps, no physical result')
|
||||
ap.add_argument('--case', choices=STRUCTURES, default='Z0',
|
||||
help='predeclared structure; all cases have zero structural damping')
|
||||
ap.add_argument('--D', type=int, choices=(20, 40, 60), default=20,
|
||||
help='diameter in cells; diffusive scaling, matched geometry and physical duration')
|
||||
ap.add_argument('--wall-seconds', type=float, default=7200., help='whole-case wall-clock cap')
|
||||
args = ap.parse_args()
|
||||
if not math.isfinite(args.wall_seconds) or args.wall_seconds <= 0:
|
||||
ap.error('--wall-seconds must be finite and positive')
|
||||
scale, time_scale = args.D / D, (args.D // int(D))**2
|
||||
velocity = U / scale
|
||||
centres, probes = scaled_points(CENTRES, scale), scaled_points(PROBES, scale)
|
||||
period = PERIOD*time_scale
|
||||
out = args.out.resolve()
|
||||
if out == ROOT or ROOT in out.parents:
|
||||
ap.error('output must be outside the checkout')
|
||||
out.mkdir(parents=True, exist_ok=False)
|
||||
cfg = config(args.D)
|
||||
(out/'config.json').write_text(json.dumps(cfg, indent=2)+'\n')
|
||||
source = subprocess.check_output(['git', '-C', str(ROOT), 'rev-parse', 'HEAD'], text=True).strip()
|
||||
mass_ratio, frequency = STRUCTURES[args.case]
|
||||
s = Structure(mass_ratio, frequency/time_scale, zeta=0., diameter=args.D)
|
||||
warmup, count = (32, 32) if args.smoke else (WARMUP*time_scale, STEPS*time_scale)
|
||||
rows = np.lib.format.open_memmap(out/'steps.npy', mode='w+', dtype=np.float64,
|
||||
shape=(count, len(COLUMNS)))
|
||||
rows[:] = np.nan
|
||||
from CelerisLab import Simulation
|
||||
from CelerisLab.body.motion import MovingTables, Translation
|
||||
names = MovingTables.DIAG
|
||||
col = {k: names.index(k) for k in names}
|
||||
status, completed, f_last = 'running', 0, 0.
|
||||
started = time.monotonic()
|
||||
report = dict(source=source, case=args.case, fixed=args.fixed, smoke=args.smoke, columns=COLUMNS,
|
||||
structure=dict(mass=s.mass, stiffness=s.stiffness, damping=s.damping,
|
||||
mass_ratio_three_disks=s.mass_ratio,
|
||||
zeta=s.zeta, fn=s.frequency, Tn=1./s.frequency, y0=0., v0=0.),
|
||||
warmup_steps=warmup, requested_steps=count, dt=1., D=args.D, U=velocity, nu=NU,
|
||||
relative_to_D20=dict(dx=1/scale, dt=1/time_scale),
|
||||
centres=centres, probes=probes, wall_planes=(.5, cfg['grid']['ny']-1.5),
|
||||
wall_cap_seconds=args.wall_seconds,
|
||||
duration_Tn=count*s.frequency, duration_tU_D=count*velocity/args.D,
|
||||
summary_block_steps=10*period,
|
||||
rhs='sum of three instantaneous CME_y + Wen_y, one-step lag, no filtering')
|
||||
try:
|
||||
with Simulation(str(out/'config.json')) as sim:
|
||||
# Match Legacy object order; probes remain passive and fixed in the lab.
|
||||
for c in probes:
|
||||
sim.add_body('sensor', c, 5.*scale)
|
||||
envelope = Translation((0., 0., -args.D, args.D), .05/scale, shared=True)
|
||||
bodies = [sim.add_body('circle', c, args.D/2, translation=envelope) for c in centres]
|
||||
sim.initialize()
|
||||
# At release obtain the last instantaneous force, not a warmup average.
|
||||
remaining = warmup - 1
|
||||
while remaining:
|
||||
if time.monotonic()-started > args.wall_seconds:
|
||||
raise TimeoutError('whole-case wall cap during warmup')
|
||||
chunk = min(10000, remaining)
|
||||
sim.run(chunk)
|
||||
remaining -= chunk
|
||||
sim.run(1)
|
||||
d = sim.moving_diagnostics()
|
||||
f_last = float(d[:, col['cme_y']].sum(dtype=np.float64) + d[:, col['wen_y']].sum(dtype=np.float64))
|
||||
report['warmup_elapsed_s'] = time.monotonic()-started
|
||||
print(json.dumps(dict(event='release', **report)), flush=True)
|
||||
for n in range(count):
|
||||
if n % 1000 == 0 and time.monotonic()-started > args.wall_seconds:
|
||||
raise TimeoutError('whole-case wall cap during response')
|
||||
y1, v1 = (0., 0.) if args.fixed else s.candidate(f_last)
|
||||
if not all(map(math.isfinite, (y1, v1))) or abs(y1) > args.D:
|
||||
status = 'stopped_nonfinite_or_travel_bound'
|
||||
break
|
||||
if not args.fixed:
|
||||
for bid in bodies:
|
||||
sim.set_body(bid, offset=(0., y1), vy=y1-s.y)
|
||||
sim.run(1)
|
||||
d = sim.moving_diagnostics()
|
||||
cme, wen = d[:, col['cme_y']], d[:, col['wen_y']]
|
||||
node = d[:, col['cover_jy']] - d[:, col['uncover_jy']]
|
||||
row = (n+1, y1, v1, *cme, *wen, *node,
|
||||
d[:, col['eq_only']].sum(), d[:, col['no_donor']].sum(), d[:, col['crossings']].sum())
|
||||
if not np.isfinite(row).all() or row[-2] != 0:
|
||||
status = 'stopped_nonfinite_or_no_donor'
|
||||
break
|
||||
rows[n] = row
|
||||
completed = n+1
|
||||
s.y, s.v = y1, v1
|
||||
f_last = float(cme.sum(dtype=np.float64)+wen.sum(dtype=np.float64))
|
||||
if completed % period == 0:
|
||||
rows.flush()
|
||||
print(json.dumps(dict(event='progress', step=completed, t_Tn=completed*s.frequency,
|
||||
y_D=s.y/args.D, elapsed_s=time.monotonic()-started)), flush=True)
|
||||
else:
|
||||
status = 'smoke_completed' if args.smoke else 'duration_completed_not_qualified'
|
||||
except BaseException as exc:
|
||||
status = f'error: {type(exc).__name__}: {exc}'
|
||||
raise
|
||||
finally:
|
||||
rows.flush()
|
||||
report.update(status=status, completed_steps=completed, elapsed_s=time.monotonic()-started,
|
||||
windows=summarize(rows[:completed], args.D, 10*period),
|
||||
totals={key: float(rows[:completed, i].sum()) for key, i in
|
||||
[('eq_only', 12), ('no_donor', 13), ('crossings', 14)]},
|
||||
limitation='Uncontrolled development response only. No automatic settling, grid convergence, control or historical-solver equivalence claim.')
|
||||
(out/'report.json').write_text(json.dumps(report, indent=2)+'\n')
|
||||
print(json.dumps(report), flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -14,6 +14,7 @@ from typing import Callable, Optional, Tuple
|
||||
|
||||
MAX_STEP = 0.5 # Euclidean centre displacement per step (lattice cells)
|
||||
BAND_MARGIN = 2 # cells added around the swept circle AABB
|
||||
SHARED_CLEARANCE = 4.0 # > two diagonal links + MAX_STEP; docs/steady_pinball_fiv.md
|
||||
R_MIN = 4.0 # smallest radius covered by the donor enumeration
|
||||
|
||||
Vec = Tuple[float, float]
|
||||
@@ -31,6 +32,7 @@ class Translation:
|
||||
"""Declared motion envelope: offset box (x_min, x_max, y_min, y_max) and speed bound."""
|
||||
offset_box: Tuple[float, float, float, float]
|
||||
max_speed: float
|
||||
shared: bool = False # same envelope instance: synchronous transverse motion
|
||||
|
||||
def __post_init__(self):
|
||||
box = tuple(float(v) for v in self.offset_box)
|
||||
@@ -41,10 +43,29 @@ class Translation:
|
||||
raise ValueError("offset_box must contain the zero offset")
|
||||
if not 0.0 < speed <= MAX_STEP:
|
||||
raise ValueError(f"max_speed must satisfy 0 < max_speed <= {MAX_STEP}")
|
||||
if self.shared and box[:2] != (0.0, 0.0):
|
||||
raise ValueError("shared translation is transverse only (x offsets must be zero)")
|
||||
object.__setattr__(self, "offset_box", box)
|
||||
object.__setattr__(self, "max_speed", speed)
|
||||
|
||||
|
||||
def validate_shared_translations(circles):
|
||||
"""Check common old/target offsets and wall velocities before device work.
|
||||
|
||||
Per-body setters may stage members in any order. Envelope identity (not
|
||||
value equality) defines a group; a missing target means hold at applied.
|
||||
"""
|
||||
groups = {}
|
||||
for motion in circles:
|
||||
if not motion.translation.shared:
|
||||
continue
|
||||
target = motion.pending if motion.pending is not None else (motion.applied, (0.0, 0.0))
|
||||
state = (motion.applied, target)
|
||||
key = id(motion.translation)
|
||||
if groups.setdefault(key, state) != state:
|
||||
raise ValueError("shared translation needs identical applied/target offsets and velocities")
|
||||
|
||||
|
||||
class MovingCircle:
|
||||
"""Applied offset, pending target and the fail-closed commit of one circle."""
|
||||
|
||||
@@ -72,6 +93,8 @@ class MovingCircle:
|
||||
x0, x1, y0, y1 = self.translation.offset_box
|
||||
if not (x0 <= offset[0] <= x1 and y0 <= offset[1] <= y1):
|
||||
raise ValueError(f"offset {offset} outside declared box {self.translation.offset_box}")
|
||||
if self.translation.shared and velocity[0] != 0.0:
|
||||
raise ValueError("shared translation needs zero x velocity")
|
||||
step = math.hypot(offset[0] - self.applied[0], offset[1] - self.applied[1])
|
||||
if step > MAX_STEP:
|
||||
raise ValueError(f"per-step displacement {step} exceeds {MAX_STEP}")
|
||||
|
||||
@@ -259,6 +259,8 @@ class Simulation:
|
||||
moving = self._moving_bodies()
|
||||
if not moving:
|
||||
return
|
||||
from .body.motion import SHARED_CLEARANCE, validate_shared_translations
|
||||
validate_shared_translations([obj.motion for obj in moving])
|
||||
cfg = self.lbm_cfg
|
||||
if (not cfg.is_d2q9 or cfg.streaming != "double_buffer" or cfg.ddf_shifting
|
||||
or cfg.store_precision != "FP32"):
|
||||
@@ -277,6 +279,13 @@ class Simulation:
|
||||
if other is obj:
|
||||
continue
|
||||
(cx, cy), r = other.center[:2], float(other.radius)
|
||||
if (other.motion is not None and obj.motion.translation.shared
|
||||
and obj.motion.translation is other.motion.translation):
|
||||
# Whole envelopes may overlap; simultaneous local stencils may not.
|
||||
gap = np.hypot(obj.center[0] - cx, obj.center[1] - cy) - obj.radius - r
|
||||
if gap <= SHARED_CLEARANCE:
|
||||
raise ValueError("shared translation needs surface clearance > 4 cells")
|
||||
continue
|
||||
if other.motion is not None:
|
||||
ox0, ox1, oy0, oy1 = other.motion.band()
|
||||
else:
|
||||
@@ -719,6 +728,9 @@ class Simulation:
|
||||
if steps < 0 or checkpoint_interval < 0:
|
||||
raise ValueError("steps and checkpoint_interval must be nonnegative")
|
||||
moving = self._moving_bodies()
|
||||
if steps > 0 and moving:
|
||||
from .body.motion import validate_shared_translations
|
||||
validate_shared_translations([obj.motion for obj in moving])
|
||||
if steps > 0 and moving and self.stepper.moving is None:
|
||||
raise RuntimeError(MOVING_BLOCKED)
|
||||
if moving and checkpoint_interval > 0:
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Small serial GPU gates for shared transverse circles; no physical benchmark."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from CelerisLab import Simulation
|
||||
from CelerisLab.body.motion import MovingTables, Translation
|
||||
|
||||
CENTRES = ((70., 79.5), (96., 94.5), (96., 64.5))
|
||||
|
||||
|
||||
def simulate(tmp_path, *, moving, offsets, reverse=False):
|
||||
config = json.loads((Path(__file__).resolve().parents[2]/'research/steady_pinball/config.json').read_text())
|
||||
config['grid'].update(nx=192, ny=160)
|
||||
p = tmp_path/'config.json'
|
||||
p.write_text(json.dumps(config))
|
||||
with Simulation(str(p)) as sim:
|
||||
envelope = Translation((0., 0., -20., 20.), .5, shared=True)
|
||||
centres = list(reversed(CENTRES)) if reverse else list(CENTRES)
|
||||
ids = [sim.add_body('circle', c, 10., translation=envelope if moving else None) for c in centres]
|
||||
sim.initialize()
|
||||
previous = 0.
|
||||
crossings = 0.
|
||||
for y in offsets:
|
||||
if moving:
|
||||
for bid in ids:
|
||||
sim.set_body(bid, offset=(0., float(y)), vy=float(y-previous))
|
||||
sim.run(1)
|
||||
if moving:
|
||||
diag = sim.moving_diagnostics()
|
||||
assert np.isfinite(diag).all()
|
||||
assert np.all(diag[:, MovingTables.DIAG.index('no_donor')] == 0)
|
||||
crossings += diag[:, MovingTables.DIAG.index('crossings')].sum()
|
||||
previous = y
|
||||
forces = np.array([sim.read_force(bid) for bid in ids])
|
||||
if reverse:
|
||||
forces = forces[::-1]
|
||||
return sim.get_ddf(), sim.get_flags(), forces, crossings
|
||||
|
||||
|
||||
def test_fixed_vs_shared_hold(tmp_path):
|
||||
fixed = simulate(tmp_path, moving=False, offsets=[0.]*32)
|
||||
held = simulate(tmp_path, moving=True, offsets=[0.]*32)
|
||||
assert np.array_equal(fixed[0], held[0], equal_nan=True)
|
||||
assert np.array_equal(fixed[1], held[1])
|
||||
# FP32 atomic force reduction order only; populations remain exactly equal.
|
||||
np.testing.assert_allclose(fixed[2], held[2], rtol=0., atol=1e-4)
|
||||
assert held[3] == 0
|
||||
|
||||
|
||||
def test_common_motion_member_order_has_no_population_effect(tmp_path):
|
||||
# MAX_STEP=.5 is a geometric bound, not a low-Mach stability guarantee.
|
||||
# Keep the complete +/-D excursion at wall speed .01, equal to mean inflow.
|
||||
offsets = np.concatenate((np.arange(1, 2001), np.arange(1999, -2001, -1),
|
||||
np.arange(-1999, 1))) * .01
|
||||
forward = simulate(tmp_path, moving=True, offsets=offsets)
|
||||
reverse = simulate(tmp_path, moving=True, offsets=offsets, reverse=True)
|
||||
assert forward[3] > 0 and forward[3] == reverse[3]
|
||||
assert np.array_equal(forward[0], reverse[0], equal_nan=True)
|
||||
assert np.array_equal(forward[1], reverse[1])
|
||||
np.testing.assert_allclose(forward[2], reverse[2], rtol=0., atol=1e-4)
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Research interface contracts; no CUDA context or physical validation."""
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
spec = importlib.util.spec_from_file_location('karman_viv_research_driver', ROOT/'research/karman_viv/run.py')
|
||||
m = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(m)
|
||||
|
||||
|
||||
def test_observation_unit_mapping_and_order():
|
||||
norm = dict(force_norm_fact=2.,sens_deviation=np.arange(6)/10.,sens_norm_fact=np.ones(6)*3)
|
||||
s=np.arange(6)*.001; f=np.arange(6)*.01
|
||||
raw,obs,nclip=m.observation(s,f,2,norm)
|
||||
np.testing.assert_array_equal(raw,np.asarray([*(s*156),*(f*2)],np.float32))
|
||||
expected=np.hstack((raw[6:]/2,(raw[:6]-np.asarray(norm['sens_deviation'],np.float32))/3))
|
||||
np.testing.assert_array_equal(obs,expected.astype(np.float32))
|
||||
assert nclip==0
|
||||
|
||||
|
||||
def test_same_physical_signal_d20_d40():
|
||||
norm=dict(force_norm_fact=1.,sens_deviation=np.zeros(6),sens_norm_fact=np.ones(6))
|
||||
a=m.observation(np.ones(6)*.001,np.arange(6)*.01,1,norm)
|
||||
b=m.observation(np.ones(6)*.0005,np.arange(6)*.005,2,norm)
|
||||
np.testing.assert_array_equal(a[0],b[0]); np.testing.assert_array_equal(a[1],b[1])
|
||||
|
||||
|
||||
def test_zero_action_and_ccw_scale():
|
||||
np.testing.assert_array_equal(m.wall_target(-m.BIAS/8,2),np.zeros(3))
|
||||
np.testing.assert_allclose(m.wall_target(np.ones(3),2),[.04,.02,.06])
|
||||
assert m.wall_target(np.ones(3),2)[0]/20 == .002
|
||||
|
||||
|
||||
def test_ema_equal_physical_time_and_interval_reset():
|
||||
target=np.array([.01,-.02,.03]); coarse=np.zeros(3); fine=np.zeros(3)
|
||||
for _ in range(13): coarse=m.ema(coarse,target,1)
|
||||
for _ in range(52): fine=m.ema(fine,target,2)
|
||||
np.testing.assert_allclose(fine,coarse,rtol=2e-15,atol=1e-17)
|
||||
np.testing.assert_allclose(m.ema(np.zeros(3),target,2),(1-.9**.25)*target)
|
||||
|
||||
|
||||
def test_config_real_parser(tmp_path):
|
||||
import json
|
||||
from CelerisLab.config import load_lbm_config
|
||||
path=tmp_path/'config.json';path.write_text(json.dumps(m.config(40)))
|
||||
cfg=load_lbm_config(str(path));cfg.validate()
|
||||
assert (cfg.nx,cfg.ny,cfg.velocity,cfg.viscosity)==(2559,1022,.005,.004)
|
||||
|
||||
|
||||
def test_structure_physical_scaling_and_no_damping():
|
||||
coarse=m.Structure(2,1e-4,0,20);fine=m.Structure(2,2.5e-5,0,40)
|
||||
assert fine.mass==4*coarse.mass
|
||||
assert fine.stiffness==coarse.stiffness/4
|
||||
assert fine.damping==0
|
||||
assert fine.candidate(0)==(0.,0.)
|
||||
|
||||
|
||||
def test_observation_clipping_and_nonfinite():
|
||||
norm=dict(force_norm_fact=1.,sens_deviation=np.zeros(6),sens_norm_fact=np.ones(6))
|
||||
_,obs,n=m.observation(np.ones(6),np.ones(6),2,norm)
|
||||
assert n==12 and np.array_equal(obs,np.ones(12,np.float32))
|
||||
with pytest.raises(RuntimeError,match='nonfinite'):
|
||||
m.observation(np.full(6,np.nan),np.zeros(6),2,norm)
|
||||
|
||||
|
||||
def test_arm_step_order_and_native_rhs(tmp_path):
|
||||
import CelerisLab
|
||||
from CelerisLab.body.motion import MovingTables
|
||||
from contextlib import nullcontext
|
||||
log=[];candidates=[]
|
||||
class Structure:
|
||||
y=v=0.;mass=2.;stiffness=3.;frequency=.1
|
||||
def __init__(self,*args): pass
|
||||
def candidate(self,f):
|
||||
candidates.append(f)
|
||||
return self.y+.001,self.v+.002
|
||||
class Sim:
|
||||
def __init__(self,*args): self.n=0
|
||||
def __enter__(self): return self
|
||||
def __exit__(self,*args): pass
|
||||
def initialize(self): pass
|
||||
def set_body(self,p,**kw): log.append(('set',p,kw))
|
||||
def run(self,n,**kw): self.n+=1;log.append(('run',n,kw))
|
||||
def moving_diagnostics(self):
|
||||
a=np.zeros((3,len(MovingTables.DIAG)),np.float32)
|
||||
a[:,MovingTables.DIAG.index('cme_y')]=self.n
|
||||
a[:,MovingTables.DIAG.index('wen_y')]=10*self.n
|
||||
a[:,MovingTables.DIAG.index('cover_jy')]=1000
|
||||
return a
|
||||
def read_force(self,p): return [0.,float(self.n),0.]
|
||||
def read_sensor(self,p): return [.001,0.,0.]
|
||||
seen=[]
|
||||
model=SimpleNamespace(predict=lambda obs,**kw:(seen.append(obs.copy()) or np.ones(3)*.1,None))
|
||||
norm=dict(force_norm_fact=1.,sens_deviation=np.zeros(6),sens_norm_fact=np.ones(6))
|
||||
with patch.object(CelerisLab,'Simulation',Sim),patch.object(m,'Structure',Structure), \
|
||||
patch.object(m,'add_scene',return_value=([1,2,3],[4,5,6])),patch.object(m,'install_seed',return_value={}), \
|
||||
patch.object(m,'check_time'):
|
||||
m.run_arm('controlled','cfg',2,2,2,None,7.,model,norm,tmp_path,0,1)
|
||||
assert candidates==[7.,33.,66.,99.] # previous native force, NOT SI mean or node impulse
|
||||
assert not seen[0].any() and len(seen)==2
|
||||
assert [e[0] for e in log]==['set','set','set','run']*4
|
||||
for e in log:
|
||||
if e[0]=='set': assert e[2]['vy']==pytest.approx(.001) and e[2]['omega']!=0
|
||||
else: assert e[2]==dict(zero_obs=True,sync_obs=True)
|
||||
rows=np.load(tmp_path/'controlled/steps.npy')
|
||||
np.testing.assert_allclose(rows[:,1],[.001,.002,.003,.004])
|
||||
assert rows.shape==(4,18)
|
||||
np.testing.assert_array_equal(rows[0,15:],rows[2,15:]) # EMA resets at SI boundary
|
||||
signals=np.load(tmp_path/'controlled/signals.npz')
|
||||
np.testing.assert_array_equal(signals['forces'][:,1],[3.,7.]) # interval CME *2, not CME+Wen
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Host admission and independent lattice-stencil checks; no CUDA context."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from CelerisLab.body.motion import MovingCircle, MovingTables, Translation, validate_shared_translations
|
||||
from CelerisLab.lbm.descriptors import FLUID
|
||||
from tests.unit.test_moving_bodies import STREAM, body, moving_simulation, routed_simulation
|
||||
|
||||
|
||||
def scene(monkeypatch, envelope=None, gap=10.):
|
||||
sim = routed_simulation(monkeypatch)
|
||||
envelope = envelope or Translation((0., 0., -6., 6.), .5, shared=True)
|
||||
for y in (40. - (20. + gap) / 2., 40. + (20. + gap) / 2.):
|
||||
sim.bodies.add(body(60., y, 10., envelope))
|
||||
sim.field = SimpleNamespace(nx=120, ny=80, build_channel_flags=lambda: np.full((80, 120), FLUID))
|
||||
return sim
|
||||
|
||||
|
||||
def test_shared_bands_are_admitted_but_other_groups_are_not(monkeypatch):
|
||||
sim = scene(monkeypatch)
|
||||
sim._validate_moving_bodies()
|
||||
a, b = [o.motion for o in sim.bodies.objects]
|
||||
assert a.band()[3] >= b.band()[2] # whole envelopes genuinely overlap
|
||||
band, geom = MovingTables.layout([(a, 0), (b, 1)])
|
||||
assert band[10] == band[2] * band[3] * 8
|
||||
assert len(geom) == 14
|
||||
b.translation = Translation((0., 0., -6., 6.), .5, shared=True)
|
||||
with pytest.raises(ValueError, match='overlaps body'):
|
||||
sim._validate_moving_bodies() # equal values do not link independent motion
|
||||
b.translation = a.translation
|
||||
sim.bodies.add(body(60., 40., 2.))
|
||||
with pytest.raises(ValueError, match='overlaps body'):
|
||||
sim._validate_moving_bodies()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('gap', (.3, 4.))
|
||||
def test_small_surface_gaps_rejected(monkeypatch, gap):
|
||||
with pytest.raises(ValueError, match='clearance'):
|
||||
scene(monkeypatch, gap=gap)._validate_moving_bodies()
|
||||
|
||||
|
||||
def test_shared_envelope_is_transverse_only():
|
||||
with pytest.raises(ValueError, match='transverse'):
|
||||
Translation((-.1, .1, -1., 1.), .5, shared=True)
|
||||
|
||||
envelope = Translation((0., 0., -1., 1.), .5, shared=True)
|
||||
with pytest.raises(ValueError, match="zero x velocity"):
|
||||
MovingCircle((0., 0.), 10., envelope).check_target((0., .1), (.1, .1))
|
||||
|
||||
def test_group_rejection_is_before_writes_and_can_be_corrected(monkeypatch):
|
||||
sim, writes = moving_simulation(monkeypatch)
|
||||
env = Translation((0., 0., -2., 2.), .5, shared=True)
|
||||
sim.bodies.objects = [body(40., y, 10., env) for y in (20., 50.)]
|
||||
a, b = [o.motion for o in sim.bodies.objects]
|
||||
sim.stepper.moving = object()
|
||||
a.set_target((0., .2), (0., .2))
|
||||
with pytest.raises(ValueError, match='identical'):
|
||||
sim.run(1, stream=STREAM)
|
||||
assert writes == [] and a.applied == b.applied == (0., 0.)
|
||||
b.set_target((0., .2), (0., .1))
|
||||
with pytest.raises(ValueError, match='identical'):
|
||||
sim.run(1, stream=STREAM)
|
||||
assert writes == []
|
||||
b.set_target((0., .2), (0., .2))
|
||||
validate_shared_translations([a, b])
|
||||
b.applied = (0., -.1)
|
||||
with pytest.raises(ValueError, match='identical'):
|
||||
validate_shared_translations([a, b])
|
||||
|
||||
|
||||
C = np.array([(x, y) for x in (-1, 0, 1) for y in (-1, 0, 1) if (x, y) != (0, 0)])
|
||||
|
||||
|
||||
def footprint_conflicts(centres, radius, dy):
|
||||
"""Enumerate geometric write/read supports, not refill/BFL formulas.
|
||||
|
||||
Tests every candidate donor, conservatively including M2's second link.
|
||||
Body-indexed slot numbers are separate from physical (cell,direction) writes.
|
||||
"""
|
||||
centres = np.asarray(centres, float)
|
||||
lo = np.floor(centres.min(0) - radius - 4).astype(int)
|
||||
hi = np.ceil(centres.max(0) + radius + 4).astype(int)
|
||||
yy, xx = np.mgrid[lo[1]:hi[1]+1, lo[0]:hi[0]+1]
|
||||
points = np.column_stack((xx.ravel(), yy.ravel()))
|
||||
def inside(p, c):
|
||||
return ((p - c)**2).sum(axis=-1) < radius**2
|
||||
old = np.array([inside(points, c) for c in centres])
|
||||
new_centres = centres + (0., dy)
|
||||
new = np.array([inside(points, c) for c in new_centres])
|
||||
changed = old ^ new
|
||||
conflicts = int(np.count_nonzero(changed.sum(0) > 1))
|
||||
writes = set()
|
||||
for i, (oc, nc) in enumerate(zip(centres, new_centres)):
|
||||
others = [j for j in range(len(centres)) if j != i]
|
||||
fresh = points[old[i] & ~new[i]]
|
||||
for e in C:
|
||||
donor = fresh + e
|
||||
valid = ~inside(donor, oc) & ~inside(donor, nc)
|
||||
for j in others:
|
||||
conflicts += int(np.count_nonzero(valid & (inside(donor, centres[j]) | inside(donor, new_centres[j]))))
|
||||
link = ~new[i] & inside(points + e, nc)
|
||||
fluid = points[link]
|
||||
solid = fluid + e
|
||||
for p in solid:
|
||||
slot = (int(p[0]), int(p[1]), int(e[0]), int(e[1]))
|
||||
conflicts += int(slot in writes)
|
||||
writes.add(slot)
|
||||
for read in (fluid, fluid - e):
|
||||
for j in others:
|
||||
conflicts += int(np.count_nonzero(inside(read, centres[j]) | inside(read, new_centres[j])))
|
||||
return conflicts
|
||||
|
||||
|
||||
def test_counterexample_and_pinball_local_ownership():
|
||||
# Close but disjoint circles are insufficient: parent/Grok independent example.
|
||||
assert footprint_conflicts(((.25, .25), (.25, 8.55)), 4., .5) > 0
|
||||
for phase in (0., .25, .5, .75):
|
||||
for dy in (-.5, -.1, .1, .5):
|
||||
centres = np.array(((0., 0.), (26., 15.), (26., -15.))) + phase
|
||||
assert footprint_conflicts(centres, 10., dy) == 0
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Real config loading and pure structural update for the frozen development case."""
|
||||
import importlib.util
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
path = Path(__file__).resolve().parents[2] / 'research/steady_pinball/run.py'
|
||||
spec = importlib.util.spec_from_file_location('steady_pinball_run', path)
|
||||
runner = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(runner)
|
||||
|
||||
|
||||
def test_actual_loader_and_paper_geometry(tmp_path):
|
||||
import json
|
||||
from CelerisLab.config import load_lbm_config
|
||||
p = tmp_path/'config.json'
|
||||
p.write_text(json.dumps(runner.config()))
|
||||
cfg = load_lbm_config(str(p))
|
||||
cfg.validate()
|
||||
assert (cfg.nx, cfg.ny, cfg.viscosity, cfg.velocity) == (1280, 512, .004, .01)
|
||||
assert cfg.collision == 'MRT' and cfg.y_wall_bc == 'bounce_back'
|
||||
assert runner.U * runner.D / runner.NU == 50.
|
||||
assert runner.CENTRES == ((600., 255.5), (626., 270.5), (626., 240.5))
|
||||
|
||||
|
||||
def test_structure_rest_equilibrium_and_energy_decay():
|
||||
s = runner.Structure(zeta=.01)
|
||||
assert s.candidate(0.) == (0., 0.)
|
||||
assert s.mass == pytest.approx(10*3*math.pi*100)
|
||||
s.y, s.v = .2, 0.
|
||||
# One damped period: independent continuous solution at total elapsed time.
|
||||
for _ in range(runner.PERIOD):
|
||||
s.y, s.v = s.candidate(0.)
|
||||
t = runner.PERIOD
|
||||
a = s.zeta*s.omega
|
||||
w = s.omega*math.sqrt(1-s.zeta**2)
|
||||
expected = .2*math.exp(-a*t)*(math.cos(w*t)+a/w*math.sin(w*t))
|
||||
assert s.y == pytest.approx(expected, abs=2e-12)
|
||||
assert .5*s.mass*s.v*s.v+.5*s.stiffness*s.y*s.y < .5*s.stiffness*.2**2
|
||||
s.y, s.v = .3, 0.
|
||||
assert s.candidate(s.stiffness*.3) == pytest.approx((.3, 0.), abs=1e-15)
|
||||
|
||||
|
||||
def test_report_uses_completed_rows_and_net_force():
|
||||
rows = np.zeros((4, 15))
|
||||
rows[:, 0] = np.arange(1, 5)
|
||||
rows[:, 1] = 2.
|
||||
rows[:, 3:9] = 1.
|
||||
got = runner.summarize(rows)
|
||||
assert got[0]['end_step'] == 4
|
||||
assert got[0]['y_rms_D'] == .1
|
||||
assert got[0]['fy_rms'] == 6.
|
||||
|
||||
|
||||
@pytest.mark.parametrize('case', ['Z0', 'M2', 'R2'])
|
||||
def test_zero_damping_cases_conserve_unforced_energy_and_match_forced_solution(case):
|
||||
s = runner.Structure(*runner.STRUCTURES[case])
|
||||
assert s.zeta == s.damping == 0.
|
||||
s.y, s.v = .2, 1.e-5
|
||||
y0, v0 = s.y, s.v
|
||||
energy = .5*s.mass*v0**2 + .5*s.stiffness*y0**2
|
||||
for _ in range(12345):
|
||||
s.y, s.v = s.candidate(0.)
|
||||
assert .5*s.mass*s.v**2 + .5*s.stiffness*s.y**2 == pytest.approx(energy, rel=2e-11)
|
||||
force, elapsed = 1.e-4, 3210
|
||||
s.y, s.v = y0, v0
|
||||
for _ in range(elapsed):
|
||||
s.y, s.v = s.candidate(force)
|
||||
phase = s.omega*elapsed
|
||||
equilibrium = force/s.stiffness
|
||||
expected_y = equilibrium+(y0-equilibrium)*math.cos(phase)+v0*math.sin(phase)/s.omega
|
||||
expected_v = -(y0-equilibrium)*s.omega*math.sin(phase)+v0*math.cos(phase)
|
||||
assert (s.y, s.v) == pytest.approx((expected_y, expected_v), abs=2e-11)
|
||||
|
||||
|
||||
def test_predeclared_comparisons_change_structure_only():
|
||||
z, m, r = [runner.Structure(*runner.STRUCTURES[k]) for k in ['Z0', 'M2', 'R2']]
|
||||
assert m.mass/z.mass == pytest.approx(.2)
|
||||
assert m.stiffness/z.stiffness == pytest.approx(.2)
|
||||
assert r.mass == m.mass
|
||||
assert r.stiffness/m.stiffness == pytest.approx(.36)
|
||||
assert [runner.STEPS*s.frequency for s in [z, m, r]] == pytest.approx([60., 60., 36.])
|
||||
|
||||
|
||||
@pytest.mark.parametrize('diameter', [20, 40, 60])
|
||||
def test_diffusive_scaling_preserves_physical_geometry_and_duration(tmp_path, diameter):
|
||||
import json
|
||||
from CelerisLab.config import load_lbm_config
|
||||
scale = diameter/20
|
||||
raw = runner.config(diameter)
|
||||
p = tmp_path/'scaled.json'
|
||||
p.write_text(json.dumps(raw))
|
||||
cfg = load_lbm_config(str(p))
|
||||
cfg.validate()
|
||||
assert cfg.velocity*diameter/cfg.viscosity == pytest.approx(50.)
|
||||
assert (cfg.nx-1)/diameter == 1279/20
|
||||
assert (cfg.ny-2)/diameter == 510/20
|
||||
for original, mapped in zip(runner.CENTRES+runner.PROBES,
|
||||
runner.scaled_points(runner.CENTRES+runner.PROBES, scale)):
|
||||
assert mapped[0]/diameter == pytest.approx(original[0]/20)
|
||||
assert (mapped[1]-.5)/diameter == pytest.approx((original[1]-.5)/20)
|
||||
old = runner.Structure(*runner.STRUCTURES['M2'])
|
||||
new = runner.Structure(2., old.frequency/scale**2, diameter=diameter)
|
||||
assert new.mass/old.mass == pytest.approx(scale**2)
|
||||
assert new.stiffness/old.stiffness == pytest.approx(1/scale**2)
|
||||
assert new.damping == 0.
|
||||
assert cfg.velocity/(new.frequency*diameter) == pytest.approx(5.)
|
||||
assert runner.STEPS*scale**2*cfg.velocity/diameter == pytest.approx(300.)
|
||||
assert runner.WARMUP*scale**2*cfg.velocity/diameter == pytest.approx(256.)
|
||||
# A physical held force maps as F_fine=F_coarse/scale in two dimensions.
|
||||
old.y, old.v = .3, .0002
|
||||
new.y, new.v = scale*old.y, old.v/scale
|
||||
expected = old.candidate(.0001)
|
||||
for _ in range(round(scale**2)):
|
||||
new.y, new.v = new.candidate(.0001/scale)
|
||||
assert (new.y/scale, new.v*scale) == pytest.approx(expected, abs=2e-12)
|
||||
|
||||
|
||||
def test_scaled_summary_uses_requested_units_and_completed_rows():
|
||||
rows = np.zeros((5, 15))
|
||||
rows[:, 0] = np.arange(1, 6)
|
||||
rows[:, 1] = 4.
|
||||
got = runner.summarize(rows, diameter=40, block_steps=3)
|
||||
assert [(x['start_step'], x['end_step']) for x in got] == [(1, 3), (4, 5)]
|
||||
assert all(x['y_rms_D'] == .1 for x in got)
|
||||
assert runner.summarize(rows[:0], diameter=40) == []
|
||||
Reference in New Issue
Block a user