modified: .gitignore
modified: README.md new file: requirement.txt new file: scripts_2/README.md new file: scripts_2/__init__.py new file: scripts_2/__pycache__/__init__.cpython-310.pyc new file: scripts_2/__pycache__/signal_utils.cpython-310.pyc new file: scripts_2/__pycache__/task3_identify.cpython-310.pyc new file: scripts_2/signal_utils.py new file: scripts_2/task3_identify.py new file: scripts_3/README.md new file: scripts_3/__init__.py new file: scripts_3/__pycache__/__init__.cpython-310.pyc new file: scripts_3/__pycache__/task4_predict_freq.cpython-310.pyc new file: scripts_3/task4_predict_freq.py
This commit is contained in:
43
scripts_2/README.md
Normal file
43
scripts_2/README.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# 结构动力特性识别脚本 (Free-Vibration Identification)
|
||||
|
||||
## 项目简介
|
||||
本目录用于完成建筑结构动力特性识别任务。脚本基于专门的自由衰减数据文件,提取顶部响应传感器 `WSMS00007` 的 Z 向信号,识别结构一阶自振频率与阻尼比,并输出可视化诊断结果。
|
||||
|
||||
## 技术路线
|
||||
本目录的分析流程围绕自由衰减信号展开,包含信号读取、主频识别、滤波降噪与阻尼估计四个步骤:
|
||||
|
||||
1. **顶部响应读取**
|
||||
从 `free_vib` 目录下的 CSV 文件中读取 `WSMS00007` 的 `value3` 列,作为结构顶部响应信号。
|
||||
2. **频域主频识别**
|
||||
对原始自由衰减信号执行 FFT,在 `0.1Hz ~ 5.0Hz` 范围内寻找主峰,识别一阶自振频率。
|
||||
3. **带通滤波**
|
||||
以识别出的主频为中心,构造巴特沃斯带通滤波器,对原始信号进行滤波,得到平滑的衰减振动波形。
|
||||
4. **阻尼比计算**
|
||||
从滤波信号中提取波峰,自动选择包络线质量较高的一段,通过对数衰减率计算平均衰减量与阻尼比。
|
||||
|
||||
## 核心模块说明
|
||||
|
||||
- **`signal_utils.py`**
|
||||
共享信号处理工具。负责传感器常量定义、采样率估计、稳态段截取、FFT 主频识别、抛物线插值、带通滤波与波峰筛选。
|
||||
- **`task3_identify.py`**
|
||||
任务主入口。负责校验自由衰减数据来源、执行一阶频率与阻尼比识别,并生成包含原始波形、滤波波形和包络线的诊断图。
|
||||
|
||||
## 使用指南
|
||||
|
||||
**1. 运行任务脚本**
|
||||
对单个自由衰减 CSV 文件执行结构动力特性识别:
|
||||
```bash
|
||||
python scripts_2\task3_identify.py --file downloads\Non_TMD\free_vib\free_decay.csv
|
||||
```
|
||||
|
||||
**2. 指定输出目录**
|
||||
将图像与诊断结果输出到自定义目录:
|
||||
```bash
|
||||
python scripts_2\task3_identify.py --file downloads\Non_TMD\free_vib\free_decay.csv --output-dir evaluation_outputs\task3_custom
|
||||
```
|
||||
|
||||
**3. 调整滤波带宽**
|
||||
通过命令行参数修改主频两侧的带宽范围:
|
||||
```bash
|
||||
python scripts_2\task3_identify.py --file downloads\Non_TMD\free_vib\free_decay.csv --bandwidth 0.25
|
||||
```
|
||||
1
scripts_2/__init__.py
Normal file
1
scripts_2/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Signal-processing scripts for free-vibration identification."""
|
||||
BIN
scripts_2/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
scripts_2/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
scripts_2/__pycache__/signal_utils.cpython-310.pyc
Normal file
BIN
scripts_2/__pycache__/signal_utils.cpython-310.pyc
Normal file
Binary file not shown.
BIN
scripts_2/__pycache__/task3_identify.cpython-310.pyc
Normal file
BIN
scripts_2/__pycache__/task3_identify.cpython-310.pyc
Normal file
Binary file not shown.
273
scripts_2/signal_utils.py
Normal file
273
scripts_2/signal_utils.py
Normal file
@@ -0,0 +1,273 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from scipy.signal import butter, filtfilt, find_peaks
|
||||
|
||||
|
||||
TOP_RESPONSE_SENSOR = "WSMS00007"
|
||||
TOP_RESPONSE_AXIS = "value3"
|
||||
BASE_REFERENCE_SENSOR = "WSMS00012"
|
||||
BASE_REFERENCE_AXIS = "value1"
|
||||
TIME_COLUMN = "time"
|
||||
CODE_COLUMN = "code"
|
||||
|
||||
|
||||
def ensure_parent_dir(path: Path) -> Path:
|
||||
path = Path(path).resolve()
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def parse_frequency_from_filename(file_name: str) -> float | None:
|
||||
match = re.search(r"(\d+(?:\.\d+)?)Hz", file_name, flags=re.IGNORECASE)
|
||||
if match is None:
|
||||
return None
|
||||
return float(match.group(1))
|
||||
|
||||
|
||||
def estimate_sampling_rate(time_values: np.ndarray) -> float:
|
||||
time_values = np.asarray(time_values, dtype=np.float64).reshape(-1)
|
||||
if time_values.size < 2:
|
||||
raise ValueError("At least two time samples are required to estimate sampling rate.")
|
||||
dt = np.diff(time_values)
|
||||
dt = dt[np.isfinite(dt)]
|
||||
dt = dt[dt > 0.0]
|
||||
if dt.size == 0:
|
||||
raise ValueError("Failed to infer a positive sampling interval from the CSV time column.")
|
||||
return float(1.0 / np.median(dt))
|
||||
|
||||
|
||||
def calculate_rms(signal: np.ndarray) -> float:
|
||||
signal = np.asarray(signal, dtype=np.float64).reshape(-1)
|
||||
return float(np.sqrt(np.mean(np.square(signal))))
|
||||
|
||||
|
||||
def read_sensor_signal(
|
||||
file_path: Path,
|
||||
sensor_code: str = TOP_RESPONSE_SENSOR,
|
||||
value_column: str = TOP_RESPONSE_AXIS,
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
df = pd.read_csv(file_path)
|
||||
sensor_df = df.loc[df[CODE_COLUMN] == sensor_code, [TIME_COLUMN, value_column]].copy()
|
||||
if sensor_df.empty:
|
||||
raise ValueError(f"Sensor {sensor_code}/{value_column} was not found in {file_path.name}.")
|
||||
|
||||
sensor_df = sensor_df.sort_values(TIME_COLUMN)
|
||||
sensor_df = sensor_df.drop_duplicates(subset=TIME_COLUMN, keep="first")
|
||||
sensor_df[TIME_COLUMN] = sensor_df[TIME_COLUMN].astype("float64")
|
||||
sensor_df[value_column] = sensor_df[value_column].astype("float64")
|
||||
|
||||
time_values = sensor_df[TIME_COLUMN].to_numpy(dtype=np.float64)
|
||||
signal = sensor_df[value_column].to_numpy(dtype=np.float64)
|
||||
if time_values.size < 8:
|
||||
raise ValueError(f"Too few usable samples in {file_path.name}.")
|
||||
return time_values, signal
|
||||
|
||||
|
||||
def get_middle_segment(
|
||||
time_values: np.ndarray,
|
||||
signal: np.ndarray,
|
||||
start_ratio: float = 0.20,
|
||||
end_ratio: float = 0.80,
|
||||
steady_window_ratio: float = 0.25,
|
||||
stride_ratio: float = 0.05,
|
||||
min_segment_length: int = 512,
|
||||
subwindow_count: int = 4,
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
signal = np.asarray(signal, dtype=np.float64).reshape(-1)
|
||||
time_values = np.asarray(time_values, dtype=np.float64).reshape(-1)
|
||||
if signal.size != time_values.size:
|
||||
raise ValueError("Time and signal arrays must have the same length.")
|
||||
|
||||
length = signal.size
|
||||
search_start = int(length * start_ratio)
|
||||
search_end = int(length * end_ratio)
|
||||
search_start = max(0, min(search_start, length - 1))
|
||||
search_end = max(search_start + 1, min(search_end, length))
|
||||
|
||||
if search_end - search_start < min_segment_length:
|
||||
center = length // 2
|
||||
half = min_segment_length // 2
|
||||
search_start = max(0, center - half)
|
||||
search_end = min(length, search_start + min_segment_length)
|
||||
search_start = max(0, search_end - min_segment_length)
|
||||
|
||||
search_length = search_end - search_start
|
||||
window_length = max(min_segment_length, int(length * steady_window_ratio))
|
||||
window_length = min(window_length, search_length)
|
||||
if search_length <= window_length:
|
||||
return time_values[search_start:search_end], signal[search_start:search_end]
|
||||
|
||||
stride = max(1, int(length * stride_ratio))
|
||||
candidate_signal = signal[search_start:search_end]
|
||||
candidate_rms = calculate_rms(candidate_signal)
|
||||
best_score = float("inf")
|
||||
best_slice = slice(search_start, search_start + window_length)
|
||||
|
||||
for window_start in range(search_start, search_end - window_length + 1, stride):
|
||||
window_end = window_start + window_length
|
||||
window_signal = signal[window_start:window_end]
|
||||
split_windows = np.array_split(window_signal, subwindow_count)
|
||||
split_rms = np.asarray([calculate_rms(chunk) for chunk in split_windows], dtype=np.float64)
|
||||
split_peaks = np.asarray([float(np.max(np.abs(chunk))) for chunk in split_windows], dtype=np.float64)
|
||||
|
||||
rms_cv = float(split_rms.std() / max(split_rms.mean(), 1e-8))
|
||||
peak_cv = float(split_peaks.std() / max(split_peaks.mean(), 1e-8))
|
||||
window_rms = calculate_rms(window_signal)
|
||||
score = rms_cv + 0.35 * peak_cv - 0.05 * (window_rms / max(candidate_rms, 1e-8))
|
||||
if score < best_score:
|
||||
best_score = score
|
||||
best_slice = slice(window_start, window_end)
|
||||
|
||||
return time_values[best_slice], signal[best_slice]
|
||||
|
||||
|
||||
def compute_windowed_spectrum(
|
||||
signal: np.ndarray,
|
||||
sampling_rate: float,
|
||||
zero_padding_factor: int = 8,
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
signal = np.asarray(signal, dtype=np.float64).reshape(-1)
|
||||
if signal.size < 4:
|
||||
raise ValueError("Signal is too short for FFT analysis.")
|
||||
|
||||
centered = signal - np.mean(signal)
|
||||
window = np.hanning(centered.size)
|
||||
fft_size = int(2 ** np.ceil(np.log2(max(centered.size * zero_padding_factor, centered.size))))
|
||||
fft_values = np.fft.rfft(centered * window, n=fft_size)
|
||||
freqs = np.fft.rfftfreq(fft_size, d=1.0 / sampling_rate)
|
||||
magnitudes = np.abs(fft_values)
|
||||
return freqs, magnitudes
|
||||
|
||||
|
||||
def parabolic_peak_frequency(freqs: np.ndarray, magnitudes: np.ndarray, peak_index: int) -> float:
|
||||
if peak_index <= 0 or peak_index >= magnitudes.size - 1:
|
||||
return float(freqs[peak_index])
|
||||
|
||||
alpha = float(magnitudes[peak_index - 1])
|
||||
beta = float(magnitudes[peak_index])
|
||||
gamma = float(magnitudes[peak_index + 1])
|
||||
denominator = alpha - 2.0 * beta + gamma
|
||||
if abs(denominator) < 1e-12:
|
||||
return float(freqs[peak_index])
|
||||
|
||||
offset = 0.5 * (alpha - gamma) / denominator
|
||||
bin_width = float(freqs[1] - freqs[0])
|
||||
return float(freqs[peak_index] + offset * bin_width)
|
||||
|
||||
|
||||
def dominant_frequency_in_band(
|
||||
signal: np.ndarray,
|
||||
sampling_rate: float,
|
||||
min_hz: float = 0.1,
|
||||
max_hz: float = 5.0,
|
||||
zero_padding_factor: int = 8,
|
||||
) -> tuple[float, np.ndarray, np.ndarray, int]:
|
||||
freqs, magnitudes = compute_windowed_spectrum(
|
||||
signal=signal,
|
||||
sampling_rate=sampling_rate,
|
||||
zero_padding_factor=zero_padding_factor,
|
||||
)
|
||||
band_mask = (freqs >= min_hz) & (freqs <= max_hz)
|
||||
if not np.any(band_mask):
|
||||
raise ValueError(f"No FFT bins fall inside {min_hz:.2f} Hz to {max_hz:.2f} Hz.")
|
||||
|
||||
band_indices = np.flatnonzero(band_mask)
|
||||
peak_index = int(band_indices[np.argmax(magnitudes[band_mask])])
|
||||
peak_frequency = parabolic_peak_frequency(freqs, magnitudes, peak_index)
|
||||
return peak_frequency, freqs, magnitudes, peak_index
|
||||
|
||||
|
||||
def bandpass_filter(
|
||||
signal: np.ndarray,
|
||||
sampling_rate: float,
|
||||
center_hz: float,
|
||||
bandwidth_hz: float = 0.3,
|
||||
order: int = 4,
|
||||
) -> np.ndarray:
|
||||
nyquist = 0.5 * sampling_rate
|
||||
low_hz = max(0.05, center_hz - bandwidth_hz)
|
||||
high_hz = min(center_hz + bandwidth_hz, nyquist * 0.98)
|
||||
if not low_hz < high_hz:
|
||||
raise ValueError("Invalid band-pass range. Check the estimated natural frequency and sampling rate.")
|
||||
|
||||
b, a = butter(order, [low_hz / nyquist, high_hz / nyquist], btype="bandpass")
|
||||
return filtfilt(b, a, np.asarray(signal, dtype=np.float64))
|
||||
|
||||
|
||||
def detect_decay_peaks(filtered_signal: np.ndarray, sampling_rate: float, frequency_hz: float) -> np.ndarray:
|
||||
filtered_signal = np.asarray(filtered_signal, dtype=np.float64).reshape(-1)
|
||||
samples_per_cycle = max(int(round(sampling_rate / max(frequency_hz, 1e-6))), 3)
|
||||
min_peak_distance = max(1, int(0.65 * samples_per_cycle))
|
||||
peaks, _ = find_peaks(filtered_signal, distance=min_peak_distance)
|
||||
if peaks.size < 4:
|
||||
peaks, _ = find_peaks(np.abs(filtered_signal), distance=max(1, min_peak_distance // 2))
|
||||
return peaks.astype(int)
|
||||
|
||||
|
||||
def select_decay_peak_window(
|
||||
filtered_signal: np.ndarray,
|
||||
peak_indices: np.ndarray,
|
||||
sampling_rate: float,
|
||||
min_peaks: int = 5,
|
||||
max_peaks: int = 12,
|
||||
) -> dict[str, np.ndarray | float | int]:
|
||||
filtered_signal = np.asarray(filtered_signal, dtype=np.float64).reshape(-1)
|
||||
peak_indices = np.asarray(peak_indices, dtype=int).reshape(-1)
|
||||
if peak_indices.size < min_peaks:
|
||||
raise ValueError("Too few peaks were detected for log-decrement damping estimation.")
|
||||
|
||||
amplitudes = np.abs(filtered_signal[peak_indices])
|
||||
tail_start = int(filtered_signal.size * 0.85)
|
||||
noise_slice = filtered_signal[tail_start:] if tail_start < filtered_signal.size else filtered_signal
|
||||
noise_floor = max(float(np.median(np.abs(noise_slice))), 1e-8)
|
||||
useful_mask = amplitudes > max(3.0 * noise_floor, 0.05 * float(amplitudes.max()))
|
||||
useful_indices = peak_indices[useful_mask]
|
||||
useful_amplitudes = amplitudes[useful_mask]
|
||||
if useful_indices.size < min_peaks:
|
||||
useful_indices = peak_indices
|
||||
useful_amplitudes = amplitudes
|
||||
|
||||
best_window: dict[str, np.ndarray | float | int] | None = None
|
||||
best_score = -float("inf")
|
||||
max_window_size = min(max_peaks, useful_indices.size)
|
||||
|
||||
for window_size in range(min_peaks, max_window_size + 1):
|
||||
for start in range(1, useful_indices.size - window_size + 1):
|
||||
stop = start + window_size
|
||||
idx_window = useful_indices[start:stop]
|
||||
amp_window = useful_amplitudes[start:stop]
|
||||
if np.any(amp_window <= 0.0):
|
||||
continue
|
||||
|
||||
time_window = idx_window.astype(np.float64) / sampling_rate
|
||||
log_amp = np.log(amp_window)
|
||||
slope, intercept = np.polyfit(time_window, log_amp, 1)
|
||||
if slope >= 0.0:
|
||||
continue
|
||||
|
||||
fit_values = slope * time_window + intercept
|
||||
ss_res = float(np.sum((log_amp - fit_values) ** 2))
|
||||
ss_tot = float(np.sum((log_amp - np.mean(log_amp)) ** 2))
|
||||
r_squared = 1.0 - ss_res / max(ss_tot, 1e-12)
|
||||
amplitude_ratio = float(amp_window[0] / max(amp_window[-1], 1e-12))
|
||||
score = r_squared + 0.02 * np.log(max(amplitude_ratio, 1.0))
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_window = {
|
||||
"peak_indices": idx_window,
|
||||
"peak_amplitudes": amp_window,
|
||||
"time_window": time_window,
|
||||
"fit_log_amplitudes": fit_values,
|
||||
"fit_amplitudes": np.exp(fit_values),
|
||||
"r_squared": r_squared,
|
||||
"noise_floor": noise_floor,
|
||||
}
|
||||
|
||||
if best_window is None:
|
||||
raise ValueError("Failed to isolate a clean decay envelope segment for damping estimation.")
|
||||
return best_window
|
||||
226
scripts_2/task3_identify.py
Normal file
226
scripts_2/task3_identify.py
Normal file
@@ -0,0 +1,226 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
|
||||
if __package__ is None or __package__ == "":
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from scripts_2.signal_utils import (
|
||||
TOP_RESPONSE_AXIS,
|
||||
TOP_RESPONSE_SENSOR,
|
||||
bandpass_filter,
|
||||
detect_decay_peaks,
|
||||
dominant_frequency_in_band,
|
||||
ensure_parent_dir,
|
||||
estimate_sampling_rate,
|
||||
read_sensor_signal,
|
||||
select_decay_peak_window,
|
||||
)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
project_root = Path(__file__).resolve().parents[1]
|
||||
parser = argparse.ArgumentParser(description="Identify the first natural frequency and damping ratio from free-decay data.")
|
||||
parser.add_argument(
|
||||
"--file",
|
||||
type=str,
|
||||
default=str(project_root / "downloads" / "Non_TMD" / "free_vib" / "free_decay.csv"),
|
||||
help="Path to a free-vibration CSV inside the dedicated free_vib folder.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-dir",
|
||||
type=str,
|
||||
default=str(project_root / "evaluation_outputs" / "task3_identify"),
|
||||
help="Directory for plots and optional exported diagnostics.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--bandwidth",
|
||||
type=float,
|
||||
default=0.3,
|
||||
help="Half-bandwidth (Hz) of the Butterworth band-pass filter around the identified natural frequency.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--show",
|
||||
action="store_true",
|
||||
help="Display the generated figure in addition to saving it.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def validate_free_vibration_source(file_path: Path) -> None:
|
||||
if "free_vib" not in {part.lower() for part in file_path.parts}:
|
||||
raise ValueError(
|
||||
f"{file_path.name} is not inside a dedicated free_vib folder. Forced-vibration tails are forbidden for task 3."
|
||||
)
|
||||
|
||||
|
||||
def build_envelope_curve(
|
||||
time_values: np.ndarray,
|
||||
peak_times: np.ndarray,
|
||||
fit_amplitudes: np.ndarray,
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
if peak_times.size < 2:
|
||||
return time_values, np.zeros_like(time_values)
|
||||
envelope = np.interp(time_values, peak_times, fit_amplitudes, left=fit_amplitudes[0], right=fit_amplitudes[-1])
|
||||
return time_values, envelope
|
||||
|
||||
|
||||
def save_task3_figure(
|
||||
file_path: Path,
|
||||
output_dir: Path,
|
||||
time_values: np.ndarray,
|
||||
raw_signal: np.ndarray,
|
||||
filtered_signal: np.ndarray,
|
||||
peak_indices: np.ndarray,
|
||||
selected_peak_indices: np.ndarray,
|
||||
selected_peak_amplitudes: np.ndarray,
|
||||
fit_amplitudes: np.ndarray,
|
||||
natural_frequency_hz: float,
|
||||
damping_ratio: float,
|
||||
) -> Path:
|
||||
output_dir = ensure_parent_dir(output_dir)
|
||||
peak_times = time_values[selected_peak_indices]
|
||||
envelope_time, envelope = build_envelope_curve(time_values, peak_times, fit_amplitudes)
|
||||
|
||||
fig, axes = plt.subplots(3, 1, figsize=(12, 10), sharex=False)
|
||||
fig.suptitle(f"Task 3 | Free-Vibration Identification | {file_path.name}")
|
||||
|
||||
axes[0].plot(time_values, raw_signal, color="tab:gray", linewidth=1.0, label="Raw top response")
|
||||
axes[0].set_title(f"Top Response ({TOP_RESPONSE_SENSOR} / {TOP_RESPONSE_AXIS})")
|
||||
axes[0].set_ylabel("Acceleration")
|
||||
axes[0].grid(True, alpha=0.25)
|
||||
axes[0].legend()
|
||||
|
||||
axes[1].plot(time_values, filtered_signal, color="tab:blue", linewidth=1.0, label="Band-pass filtered")
|
||||
axes[1].scatter(time_values[peak_indices], filtered_signal[peak_indices], color="tab:orange", s=14, label="Detected peaks")
|
||||
axes[1].set_title(f"Filtered around f_n = {natural_frequency_hz:.4f} Hz")
|
||||
axes[1].set_ylabel("Acceleration")
|
||||
axes[1].grid(True, alpha=0.25)
|
||||
axes[1].legend()
|
||||
|
||||
axes[2].plot(time_values, filtered_signal, color="tab:blue", linewidth=1.0, alpha=0.65, label="Filtered signal")
|
||||
axes[2].scatter(peak_times, selected_peak_amplitudes, color="tab:red", s=28, label="Selected envelope peaks")
|
||||
axes[2].plot(envelope_time, envelope, color="tab:green", linewidth=1.8, label="Fitted positive envelope")
|
||||
axes[2].plot(envelope_time, -envelope, color="tab:green", linewidth=1.2, linestyle="--", label="Fitted negative envelope")
|
||||
axes[2].set_title(f"Selected decay segment | damping ratio zeta = {damping_ratio:.5f}")
|
||||
axes[2].set_xlabel("Time (s)")
|
||||
axes[2].set_ylabel("Acceleration")
|
||||
axes[2].grid(True, alpha=0.25)
|
||||
axes[2].legend()
|
||||
|
||||
plt.tight_layout()
|
||||
figure_path = output_dir / f"{file_path.stem}_task3_identify.png"
|
||||
plt.savefig(figure_path, dpi=180, bbox_inches="tight")
|
||||
return figure_path
|
||||
|
||||
|
||||
def analyze_free_vibration(
|
||||
file_path: Path,
|
||||
output_dir: Path,
|
||||
bandwidth_hz: float = 0.3,
|
||||
show: bool = False,
|
||||
) -> dict[str, float | int | str | Path]:
|
||||
file_path = Path(file_path).resolve()
|
||||
validate_free_vibration_source(file_path)
|
||||
|
||||
time_values, raw_signal = read_sensor_signal(file_path, sensor_code=TOP_RESPONSE_SENSOR, value_column=TOP_RESPONSE_AXIS)
|
||||
sampling_rate = estimate_sampling_rate(time_values)
|
||||
natural_frequency_hz, freqs, magnitudes, peak_index = dominant_frequency_in_band(
|
||||
raw_signal,
|
||||
sampling_rate=sampling_rate,
|
||||
min_hz=0.1,
|
||||
max_hz=5.0,
|
||||
zero_padding_factor=16,
|
||||
)
|
||||
filtered_signal = bandpass_filter(
|
||||
raw_signal,
|
||||
sampling_rate=sampling_rate,
|
||||
center_hz=natural_frequency_hz,
|
||||
bandwidth_hz=bandwidth_hz,
|
||||
order=4,
|
||||
)
|
||||
peak_indices = detect_decay_peaks(filtered_signal, sampling_rate=sampling_rate, frequency_hz=natural_frequency_hz)
|
||||
peak_window = select_decay_peak_window(filtered_signal, peak_indices, sampling_rate=sampling_rate)
|
||||
|
||||
selected_peak_indices = np.asarray(peak_window["peak_indices"], dtype=int)
|
||||
selected_peak_amplitudes = np.asarray(peak_window["peak_amplitudes"], dtype=np.float64)
|
||||
fit_amplitudes = np.asarray(peak_window["fit_amplitudes"], dtype=np.float64)
|
||||
log_decrements = np.log(selected_peak_amplitudes[:-1] / selected_peak_amplitudes[1:])
|
||||
mean_log_decrement = float(np.mean(log_decrements))
|
||||
damping_ratio = float(mean_log_decrement / (2.0 * np.pi))
|
||||
damping_ratio_exact = float(
|
||||
mean_log_decrement / np.sqrt((2.0 * np.pi) ** 2 + mean_log_decrement**2)
|
||||
)
|
||||
|
||||
figure_path = save_task3_figure(
|
||||
file_path=file_path,
|
||||
output_dir=output_dir,
|
||||
time_values=time_values,
|
||||
raw_signal=raw_signal,
|
||||
filtered_signal=filtered_signal,
|
||||
peak_indices=peak_indices,
|
||||
selected_peak_indices=selected_peak_indices,
|
||||
selected_peak_amplitudes=selected_peak_amplitudes,
|
||||
fit_amplitudes=fit_amplitudes,
|
||||
natural_frequency_hz=natural_frequency_hz,
|
||||
damping_ratio=damping_ratio,
|
||||
)
|
||||
if show:
|
||||
plt.show()
|
||||
plt.close("all")
|
||||
|
||||
return {
|
||||
"file_path": str(file_path),
|
||||
"sensor_code": TOP_RESPONSE_SENSOR,
|
||||
"axis": TOP_RESPONSE_AXIS,
|
||||
"sampling_rate_hz": float(sampling_rate),
|
||||
"natural_frequency_hz": float(natural_frequency_hz),
|
||||
"fft_peak_bin_hz": float(freqs[peak_index]),
|
||||
"fft_peak_magnitude": float(magnitudes[peak_index]),
|
||||
"detected_peak_count": int(len(peak_indices)),
|
||||
"selected_peak_count": int(len(selected_peak_indices)),
|
||||
"mean_log_decrement": mean_log_decrement,
|
||||
"damping_ratio": damping_ratio,
|
||||
"damping_ratio_exact": damping_ratio_exact,
|
||||
"envelope_r2": float(peak_window["r_squared"]),
|
||||
"figure_path": str(figure_path),
|
||||
}
|
||||
|
||||
|
||||
def print_report(result: dict[str, float | int | str | Path]) -> None:
|
||||
print("=== Task 3: Structural Dynamic Identification ===")
|
||||
print(f"Input file: {result['file_path']}")
|
||||
print(f"Top response sensor: {result['sensor_code']} / {result['axis']}")
|
||||
print(f"Sampling rate: {result['sampling_rate_hz']:.4f} Hz")
|
||||
print(f"Natural frequency f_n: {result['natural_frequency_hz']:.6f} Hz")
|
||||
print(f"Mean log decrement delta: {result['mean_log_decrement']:.6f}")
|
||||
print(f"Damping ratio zeta (delta / 2pi): {result['damping_ratio']:.6f}")
|
||||
print(f"Damping ratio exact: {result['damping_ratio_exact']:.6f}")
|
||||
print(f"Detected peaks: {result['detected_peak_count']}")
|
||||
print(f"Selected peaks for envelope: {result['selected_peak_count']}")
|
||||
print(f"Envelope linearity R^2: {result['envelope_r2']:.6f}")
|
||||
print(f"Figure saved to: {result['figure_path']}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
result = analyze_free_vibration(
|
||||
file_path=Path(args.file),
|
||||
output_dir=Path(args.output_dir),
|
||||
bandwidth_hz=float(args.bandwidth),
|
||||
show=bool(args.show),
|
||||
)
|
||||
print_report(result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user