Metadata-Version: 2.4
Name: dynaresp
Version: 0.1.0
Summary: Structural-dynamics simulation and synthetic-data generation
Author: Yacine Bel-Hadj
License-Expression: GPL-3.0-or-later
License-File: LICENSE
Keywords: Beam,Environmental and Operational Conditions,MDOF,Simulation,Structural dynamics,Synthetic data
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Science/Research
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering
Requires-Python: <3.14,>=3.11
Requires-Dist: numpy>=1.26
Requires-Dist: scipy>=1.12
Description-Content-Type: text/markdown

# DynaResp

DynaResp generates structural-dynamics responses from validated matrix-defined
MDOF systems and analytical Euler-Bernoulli beams. Use `model.excite()` for one
response, or `Study` to repeat an experiment with built-in distributions or
your own NumPy RNG-driven sampling functions. A study can retain complete
responses and model matrices as well as compact sensor measurements.

DynaResp is not a finite-element solver. You can provide any compatible mass,
damping, and stiffness matrices, while the analytical beam model does not create
a mesh. Version 0.1 stores dense generated datasets as compressed NPZ files.

## Install

DynaResp requires Python 3.11-3.13.

```bash
pip install .
```

For the locked development environment:

```bash
uv sync --group dev
```

## Guided notebook

The executable [DynaResp walkthrough](examples/dynaresp_walkthrough.py) covers
interactive force controls, MDOF simulations, an animated two-axle car crossing
a beam, sensors, custom sampling functions, complete studies, dataset
persistence, and reproductions of published MIT MDOF and Georgia Tech beam
benchmarks. It is a reactive
[marimo](https://marimo.io/) notebook:

~~~bash
uv sync --group notebooks
uv run --group notebooks marimo edit examples/dynaresp_walkthrough.py
~~~

## Force functions

Forces are ordinary functions, not classes. Calling `harmonic_force(...)`,
`moving_force(...)`, or another force-generating function immediately returns a
NumPy matrix. These functions receive one-dimensional `time_dimension` and
`space_dimension` vectors and return a matrix with shape
`(len(space_dimension), len(time_dimension))`: rows are spatial DOFs and columns
are time samples. Helper functions such as `combine_forces(...)` instead operate
on force matrices that have already been created.

## Simulate one MDOF response

```python
import numpy as np

from dynaresp import MDOF, harmonic_force

model = MDOF(
    mass=np.diag([2_000.0, 1_500.0]),
    stiffness=np.array([[3.0e6, -1.0e6], [-1.0e6, 1.0e6]]),
    damping=np.array([[1_200.0, -400.0], [-400.0, 800.0]]),
    labels=("lower", "upper"),
)

time_dimension = np.arange(0.0, 5.0, 1.0 / 500.0)
load = harmonic_force(
    time_dimension,
    space_dimension=model.space_dimension,
    dof=1,
    amplitude=10_000.0,
    frequency=3.0,
)

mdof_response = model.excite(load, time_dimension)
print(mdof_response.acceleration.shape)  # (2, 2500)
```

The response contains `displacement`, `velocity`, `acceleration`, `force`, and
`time_dimension`. Forces use zero-order hold by default, which preserves step
and pulse switching times. Pass `interpolate_force=True` to `model.excite()`
for linear interpolation of smooth sampled histories.

## Assemble masses in series

`MassesInSeries` builds the full matrices from one value per level. The first
spring and damper connect the first mass to ground. Every following spring and
damper connects its mass to the mass below it.

```python
from dynaresp import MassesInSeries

model = MassesInSeries(
    mass=[2_000.0, 1_500.0],
    stiffness=[2.0e6, 1.0e6],
    damping=[800.0, 400.0],
    labels=("lower", "upper"),
)

print(model.mass)
print(model.damping)
print(model.stiffness)
print(model.natural_frequencies)
print(model.mode_shapes)
```

MDOF mode-shape matrices contain one mode per row.

## Simulate an analytical beam

```python
import numpy as np

from dynaresp import Beam, moving_force

beam = Beam(
    length=20.0,
    young_modulus=30.0e9,
    density=2_500.0,
    area=1.2,
    second_moment=0.18,
    boundary="simply_supported",
    damping=0.02,
    n_modes=4,
)

time_dimension = np.arange(0.0, 2.0, 1.0 / 1_000.0)
axle = moving_force(
    time_dimension,
    space_dimension=beam.space_dimension,
    mode_shapes=beam.mode_shapes,
    length=beam.length,
    amplitude=80_000.0,
    speed=15.0,
)

beam_response = beam.excite(axle, time_dimension)
midspan_acceleration = beam_response.at_positions(
    [10.0],
    quantity="acceleration",
)
print(midspan_acceleration.shape)  # (1, 2000)
```

Beam parameters are mutable and remain validated when changed. Derived
properties such as `modal_mass`, `natural_frequencies`, `mass`, `damping`, and
`stiffness` are recalculated from the current values whenever they are
accessed.

## Measure a response

`Sensor` selects one response quantity at one location:

| Sensor quantity | Response returned |
| --- | --- |
| `"acc"` | acceleration |
| `"vel"` | velocity |
| `"dis"` | displacement |

For a Beam, `location` is a physical position:

```python
from dynaresp import Sensor

midspan_sensor = Sensor("acc", location=beam.length / 2.0)
midspan_acceleration = midspan_sensor.measure(beam_response)
print(midspan_acceleration.shape)  # (2000,)
```

For an MDOF model, `location` is the zero-based DOF index:

```python
dof_sensor = Sensor("dis", location=1)
dof_displacement = dof_sensor.measure(mdof_response)
```

In both cases, `measure(...)` returns one vector with the same length as the
response's `time_dimension`.

## Generate a study

`Study` runs the same experiment many times. On every run it:

1. samples the environmental and operational variables (EOVs),
2. builds the model with the sampled values,
3. builds and applies the load,
4. records every sensor, and
5. records the requested model outputs.

The `model` and `load` arguments are normal Python functions. `Study` calls them
for every sample; the load function then calls `harmonic_force(...)`,
`moving_force(...)`, or whichever force function the experiment needs.

```python
import numpy as np

from dynaresp import Beam, Dataset, Normal, Sensor, Study, moving_force


def make_model(eov):
    return Beam(
        length=20.0,
        young_modulus=eov["young_modulus"],
        density=2_500.0,
        area=1.2,
        second_moment=0.18,
        boundary="simply_supported",
        damping=0.02,
        n_modes=4,
    )


def make_load(eov, model, time_dimension):
    return moving_force(
        time_dimension,
        space_dimension=model.space_dimension,
        mode_shapes=model.mode_shapes,
        length=model.length,
        amplitude=eov["axle_load"],
        speed=eov["speed"],
    )


def sample_speed(rng):
    # Any callable receiving numpy.random.Generator can be a study variable.
    return rng.triangular(12.0, 15.0, 20.0)


study = Study(
    model=make_model,
    load=make_load,
    sensors=[Sensor("acc", location=10.0)],
    variability={
        "young_modulus": Normal(30.0e9, 1.0e9),
        "axle_load": Normal(80_000.0, 4_000.0),
        "speed": sample_speed,
    },
    duration=2.0,
    sample_rate=1_000.0,
    outputs=("natural_frequencies", "mode_shapes"),
    mode_shape_positions=np.linspace(0.0, 20.0, 101),
)

dataset = study.generate(100, seed=42)
dataset.save("traffic-study.npz")

loaded = Dataset.load("traffic-study.npz")

print(dataset.measurements.shape)  # (100, 1, 2000)
print(dataset.eov["speed"].shape)  # (100,)
print(dataset.natural_frequencies.shape)  # (100, 4)
print(dataset.mode_shapes.shape)  # (100, 4, 101)
```

`Normal(mean, standard_deviation)` and `Uniform(low, high)` are convenient
built-in distributions. Any function with the shape `sampler(rng) -> float`
can implement a triangular, log-normal, empirical, correlated, or domain-specific
distribution. The study passes the same seeded `numpy.random.Generator` through
the sampling sequence, and every returned value is saved in `dataset.eov`.

The main dataset arrays are:

- `measurements`: `(samples, sensors, time)`
- each `eov` value: `(samples,)`
- `force`, `displacement`, `velocity`, and `acceleration`:
  `(samples, dof, time)`
- `mass`, `damping`, and `stiffness`: `(samples, dof, dof)`
- `natural_frequencies`: `(samples, modes)` when requested
- `mode_shapes`: `(samples, modes, positions)` when requested
- `time_dimension`, `sensor_quantities`, `sensor_locations`, and
  `model_types`
- JSON-safe `metadata` describing the seed, study configuration, samplers, and
  every generated model

The second axis of `measurements` follows the order of the `sensors` list passed
to `Study`. For example, `dataset.measurements[:, 0, :]` contains the first
sensor for every generated sample.

For a Beam study, `mode_shape_positions` gives the physical positions where the
mode shapes are evaluated. For an MDOF study, the DOF positions are stored
automatically.

Complete response histories and matrices are recorded by default. Set
`record_full_response=False` for large studies that only need sensor histories
and selected modal outputs.

The versioned NPZ file is compressed and pickle-free. It contains generated
numeric values and descriptive metadata, not executable Python model, load, or
sampler functions. Keep the study code under version control when exact
reproduction is important. Files written by the original schema version remain
loadable; unknown future schema versions are rejected instead of being silently
misinterpreted.

## Scope and physical assumptions

- Inputs use a consistent SI unit system; DynaResp does not attach or convert
  units.
- MDOF mass and stiffness matrices must be finite and symmetric, mass must be
  positive definite, and stiffness must be positive semidefinite.
- A Beam response is a truncated analytical modal expansion, not a spatial
  discretization.
- Traffic loads are prescribed moving forces. Vehicle suspension, road
  roughness, and coupled vehicle-bridge interaction are outside version 0.1.
- Sensors are ideal selectors. Noise, filtering, clipping, and dropout are not
  added automatically.

## Development

```bash
uv run --group dev ruff check .
uv run --group dev ruff format --check .
uv run --group dev mypy
uv run --group dev pytest --cov=dynaresp
uv build
uv run --group dev twine check dist/*
```

Install the configured commit-message hook with:

```bash
uv run --group dev prek install --hook-type commit-msg
```
