|
@@ -1,11 +1,13 @@
|
|
|
"""Step 3: long-running TSPulse anomaly detection poller.
|
|
"""Step 3: long-running TSPulse anomaly detection poller.
|
|
|
|
|
|
|
|
Polls the wave_file table for rows matching ``rpm > 0 AND measurement_type =
|
|
Polls the wave_file table for rows matching ``rpm > 0 AND measurement_type =
|
|
|
-'压力'``, reads only the first 3000 wave_sample rows per file, locates the
|
|
|
|
|
-first complete crankshaft cycle, and encodes it with the frozen TSPulse model
|
|
|
|
|
-into a 128-dim fingerprint. The fingerprint is compared against the part's
|
|
|
|
|
-centroid and radius from the baseline table and ``tspluse_status`` is written
|
|
|
|
|
-back as a proportional integer of the centroid distance:
|
|
|
|
|
|
|
+'压力'``, reads each file's samples with an adaptive multi-stage window
|
|
|
|
|
+(``STAGE_LIMITS`` = 3500 -> 7000 -> 14000 rows), escalating until a complete
|
|
|
|
|
+crankshaft cycle is found or the file's data is exhausted, and encodes the
|
|
|
|
|
+first complete cycle with the frozen TSPulse model into a 128-dim fingerprint.
|
|
|
|
|
+The fingerprint is compared against the part's centroid and radius from the
|
|
|
|
|
+baseline table and ``tspluse_status`` is written back as a proportional
|
|
|
|
|
+integer of the centroid distance:
|
|
|
|
|
|
|
|
* 无周期 (no complete cycle found) -> ``-1``
|
|
* 无周期 (no complete cycle found) -> ``-1``
|
|
|
* 正常 (distance <= 2 x radius) -> ``0``
|
|
* 正常 (distance <= 2 x radius) -> ``0``
|
|
@@ -15,18 +17,21 @@ Every processed file is written back, so a full re-run from an empty watermark
|
|
|
cleanly replaces any previously stored levels.
|
|
cleanly replaces any previously stored levels.
|
|
|
|
|
|
|
|
The samples of a whole batch of files are read with one ``IN (...) AND
|
|
The samples of a whole batch of files are read with one ``IN (...) AND
|
|
|
-sample_index < 3000`` query and all candidate cycles are encoded in a single
|
|
|
|
|
-forward pass, so the network round trips and MPS kernel launches are amortised
|
|
|
|
|
-across the batch. Files are processed in ``(sample_time, id)`` ascending order
|
|
|
|
|
-and a tuple watermark on those two columns prevents re-processing files that
|
|
|
|
|
-have already been seen, so the poller first drains the backlog and then keeps
|
|
|
|
|
-watching for newly inserted rows.
|
|
|
|
|
|
|
+sample_index < {stage_limit}`` query per stage and all candidate cycles are
|
|
|
|
|
+encoded in a single forward pass, so the network round trips and MPS kernel
|
|
|
|
|
+launches are amortised across the batch. Only files that still report no cycle
|
|
|
|
|
+at the current stage are re-fetched at the next, larger window; a file is
|
|
|
|
|
+finally marked 无周期 only when no window yields a cycle. Files are processed
|
|
|
|
|
+in ``(sample_time, id)`` ascending order and a tuple watermark on those two
|
|
|
|
|
+columns prevents re-processing files that have already been seen, so the
|
|
|
|
|
+poller first drains the backlog and then keeps watching for newly inserted
|
|
|
|
|
+rows.
|
|
|
|
|
|
|
|
Usage:
|
|
Usage:
|
|
|
conda activate tspulse
|
|
conda activate tspulse
|
|
|
python predict.py [--start-time ""] [--start-id 0] [--interval 60]
|
|
python predict.py [--start-time ""] [--start-id 0] [--interval 60]
|
|
|
[--batch 100] [--limit 0] [--report-every 100]
|
|
[--batch 100] [--limit 0] [--report-every 100]
|
|
|
- [--write-batch 10] [--dry-run]
|
|
|
|
|
|
|
+ [--write-batch 100] [--dry-run]
|
|
|
"""
|
|
"""
|
|
|
|
|
|
|
|
import argparse
|
|
import argparse
|
|
@@ -50,7 +55,7 @@ HERE = Path(__file__).resolve().parent
|
|
|
CHECKPOINT_PATH = HERE / "checkpoints" / "tspulse_frozen.pt"
|
|
CHECKPOINT_PATH = HERE / "checkpoints" / "tspulse_frozen.pt"
|
|
|
BASELINE_PATH = HERE / "baseline" / "baseline.npz"
|
|
BASELINE_PATH = HERE / "baseline" / "baseline.npz"
|
|
|
|
|
|
|
|
-SAMPLE_LIMIT = 3000
|
|
|
|
|
|
|
+STAGE_LIMITS = (3500, 7000, 14000)
|
|
|
MEASUREMENT_TYPE = "压力"
|
|
MEASUREMENT_TYPE = "压力"
|
|
|
|
|
|
|
|
ANOMALY_FACTOR = 2.0
|
|
ANOMALY_FACTOR = 2.0
|
|
@@ -74,8 +79,8 @@ def load_baseline() -> tuple[dict[str, int], np.ndarray, np.ndarray]:
|
|
|
return part_index, data["centroids"], data["radii"]
|
|
return part_index, data["centroids"], data["radii"]
|
|
|
|
|
|
|
|
|
|
|
|
|
-def _fetch_chunk(connection, file_ids: list[int]) -> dict[int, np.ndarray]:
|
|
|
|
|
- """Fetch the first SAMPLE_LIMIT rows of a chunk of files in one query."""
|
|
|
|
|
|
|
+def _fetch_chunk(connection, file_ids: list[int], limit: int) -> dict[int, np.ndarray]:
|
|
|
|
|
+ """Fetch the first ``limit`` rows of a chunk of files in one query."""
|
|
|
if not file_ids:
|
|
if not file_ids:
|
|
|
return {}
|
|
return {}
|
|
|
placeholders = ",".join(["%s"] * len(file_ids))
|
|
placeholders = ",".join(["%s"] * len(file_ids))
|
|
@@ -89,7 +94,7 @@ def _fetch_chunk(connection, file_ids: list[int]) -> dict[int, np.ndarray]:
|
|
|
WHERE wave_file_id IN ({placeholders}) AND sample_index < %s
|
|
WHERE wave_file_id IN ({placeholders}) AND sample_index < %s
|
|
|
ORDER BY wave_file_id, sample_index
|
|
ORDER BY wave_file_id, sample_index
|
|
|
""",
|
|
""",
|
|
|
- (*file_ids, SAMPLE_LIMIT),
|
|
|
|
|
|
|
+ (*file_ids, limit),
|
|
|
)
|
|
)
|
|
|
rows = cursor.fetchall()
|
|
rows = cursor.fetchall()
|
|
|
grouped: dict[int, tuple[list[float], list[float]]] = {}
|
|
grouped: dict[int, tuple[list[float], list[float]]] = {}
|
|
@@ -110,8 +115,9 @@ def read_batch_samples(
|
|
|
reader_connections: list,
|
|
reader_connections: list,
|
|
|
file_ids: list[int],
|
|
file_ids: list[int],
|
|
|
workers: int,
|
|
workers: int,
|
|
|
|
|
+ limit: int,
|
|
|
) -> dict[int, np.ndarray]:
|
|
) -> dict[int, np.ndarray]:
|
|
|
- """Fetch the first SAMPLE_LIMIT rows of every file in one round-trip.
|
|
|
|
|
|
|
+ """Fetch the first ``limit`` rows of every file in one round-trip.
|
|
|
|
|
|
|
|
The batch is split into ``workers`` chunks fetched on parallel persistent
|
|
The batch is split into ``workers`` chunks fetched on parallel persistent
|
|
|
connections. Returns {file_id: (n, 3) array} with columns
|
|
connections. Returns {file_id: (n, 3) array} with columns
|
|
@@ -120,11 +126,11 @@ def read_batch_samples(
|
|
|
if not file_ids:
|
|
if not file_ids:
|
|
|
return {}
|
|
return {}
|
|
|
if workers <= 1 or len(file_ids) <= workers:
|
|
if workers <= 1 or len(file_ids) <= workers:
|
|
|
- return _fetch_chunk(reader_connections[0], file_ids)
|
|
|
|
|
|
|
+ return _fetch_chunk(reader_connections[0], file_ids, limit)
|
|
|
chunks = np.array_split(file_ids, min(workers, len(file_ids)))
|
|
chunks = np.array_split(file_ids, min(workers, len(file_ids)))
|
|
|
with ThreadPoolExecutor(max_workers=len(chunks)) as executor:
|
|
with ThreadPoolExecutor(max_workers=len(chunks)) as executor:
|
|
|
futures = [
|
|
futures = [
|
|
|
- executor.submit(_fetch_chunk, reader_connections[i], chunk.tolist())
|
|
|
|
|
|
|
+ executor.submit(_fetch_chunk, reader_connections[i], chunk.tolist(), limit)
|
|
|
for i, chunk in enumerate(chunks)
|
|
for i, chunk in enumerate(chunks)
|
|
|
]
|
|
]
|
|
|
merged: dict[int, np.ndarray] = {}
|
|
merged: dict[int, np.ndarray] = {}
|
|
@@ -168,7 +174,7 @@ def fetch_batch(
|
|
|
if watermark_time is None:
|
|
if watermark_time is None:
|
|
|
cursor.execute(
|
|
cursor.execute(
|
|
|
"""
|
|
"""
|
|
|
- SELECT id, point_name, measurement_type, sample_time
|
|
|
|
|
|
|
+ SELECT id, point_name, measurement_type, sample_time, sample_count
|
|
|
FROM wave_file
|
|
FROM wave_file
|
|
|
WHERE rpm > 0 AND measurement_type = %s
|
|
WHERE rpm > 0 AND measurement_type = %s
|
|
|
ORDER BY sample_time ASC, id ASC
|
|
ORDER BY sample_time ASC, id ASC
|
|
@@ -179,7 +185,7 @@ def fetch_batch(
|
|
|
else:
|
|
else:
|
|
|
cursor.execute(
|
|
cursor.execute(
|
|
|
"""
|
|
"""
|
|
|
- SELECT id, point_name, measurement_type, sample_time
|
|
|
|
|
|
|
+ SELECT id, point_name, measurement_type, sample_time, sample_count
|
|
|
FROM wave_file
|
|
FROM wave_file
|
|
|
WHERE rpm > 0 AND measurement_type = %s
|
|
WHERE rpm > 0 AND measurement_type = %s
|
|
|
AND (sample_time > %s OR (sample_time = %s AND id > %s))
|
|
AND (sample_time > %s OR (sample_time = %s AND id > %s))
|
|
@@ -191,6 +197,55 @@ def fetch_batch(
|
|
|
return cursor.fetchall()
|
|
return cursor.fetchall()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def resolve_batch_cycles(
|
|
|
|
|
+ rows: list[dict],
|
|
|
|
|
+ part_of: dict[int, str],
|
|
|
|
|
+ part_index: dict[str, int],
|
|
|
|
|
+ reader_connections: list,
|
|
|
|
|
+ workers: int,
|
|
|
|
|
+ first_stage: int = 0,
|
|
|
|
|
+) -> tuple[dict[int, tuple[str, np.ndarray | None]], int]:
|
|
|
|
|
+ """Adaptively detect one cycle per file with progressively larger windows.
|
|
|
|
|
+
|
|
|
|
|
+ The whole batch is first fetched at ``STAGE_LIMITS[first_stage]``. Files
|
|
|
|
|
+ that still report 无周期 and have more rows than the current window are
|
|
|
|
|
+ re-fetched at the next stage, up to ``STAGE_LIMITS[-1]``; a file is only
|
|
|
|
|
+ finally 无周期 when no window yields a cycle. ``first_stage`` lets callers
|
|
|
|
|
+ (predict_re) skip cheap windows for files already known to need more data.
|
|
|
|
|
+
|
|
|
|
|
+ Returns (``{file_id: (status, matrix)}``, error_count). Status is "ok" or a
|
|
|
|
|
+ skip reason, mirroring ``prepare_cycle``.
|
|
|
|
|
+ """
|
|
|
|
|
+ errors = 0
|
|
|
|
|
+ resolved: dict[int, tuple[str, np.ndarray | None]] = {}
|
|
|
|
|
+ stage_files: dict[int, dict] = {int(row["id"]): row for row in rows}
|
|
|
|
|
+ for stage in range(first_stage, len(STAGE_LIMITS)):
|
|
|
|
|
+ if not stage_files:
|
|
|
|
|
+ break
|
|
|
|
|
+ limit = STAGE_LIMITS[stage]
|
|
|
|
|
+ samples_map = read_batch_samples(reader_connections, list(stage_files), workers, limit)
|
|
|
|
|
+ next_pending: dict[int, dict] = {}
|
|
|
|
|
+ for file_id, row in stage_files.items():
|
|
|
|
|
+ samples = samples_map.get(file_id)
|
|
|
|
|
+ if samples is None or len(samples) == 0:
|
|
|
|
|
+ resolved[file_id] = ("无样本", None)
|
|
|
|
|
+ continue
|
|
|
|
|
+ try:
|
|
|
|
|
+ status, matrix = prepare_cycle(samples, part_of[file_id], part_index)
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ errors += 1
|
|
|
|
|
+ resolved[file_id] = ("错误", None)
|
|
|
|
|
+ continue
|
|
|
|
|
+ if status == "无周期" and int(row.get("sample_count") or 0) > limit:
|
|
|
|
|
+ next_pending[file_id] = row
|
|
|
|
|
+ else:
|
|
|
|
|
+ resolved[file_id] = (status, matrix)
|
|
|
|
|
+ stage_files = next_pending
|
|
|
|
|
+ for file_id in stage_files:
|
|
|
|
|
+ resolved[file_id] = ("无周期", None)
|
|
|
|
|
+ return resolved, errors
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
def compute_status(distance: float, radius: float) -> int:
|
|
def compute_status(distance: float, radius: float) -> int:
|
|
|
"""Map a fingerprint distance to a proportional integer status.
|
|
"""Map a fingerprint distance to a proportional integer status.
|
|
|
|
|
|
|
@@ -327,26 +382,18 @@ def main() -> int:
|
|
|
|
|
|
|
|
file_ids = [int(row["id"]) for row in rows]
|
|
file_ids = [int(row["id"]) for row in rows]
|
|
|
part_of = {int(row["id"]): f"{row['point_name']}_{row['measurement_type']}" for row in rows}
|
|
part_of = {int(row["id"]): f"{row['point_name']}_{row['measurement_type']}" for row in rows}
|
|
|
- sample_map = read_batch_samples(reader_connections, file_ids, read_workers)
|
|
|
|
|
|
|
+ resolved, batch_errors = resolve_batch_cycles(
|
|
|
|
|
+ rows, part_of, part_index, reader_connections, read_workers,
|
|
|
|
|
+ )
|
|
|
|
|
+ errors += batch_errors
|
|
|
|
|
|
|
|
candidates: list[tuple[int, str, np.ndarray]] = []
|
|
candidates: list[tuple[int, str, np.ndarray]] = []
|
|
|
for row in rows:
|
|
for row in rows:
|
|
|
file_id = int(row["id"])
|
|
file_id = int(row["id"])
|
|
|
- part = part_of[file_id]
|
|
|
|
|
- samples = sample_map.get(file_id)
|
|
|
|
|
- if samples is None or len(samples) == 0:
|
|
|
|
|
- status, matrix = "无样本", None
|
|
|
|
|
- else:
|
|
|
|
|
- try:
|
|
|
|
|
- status, matrix = prepare_cycle(samples, part, part_index)
|
|
|
|
|
- except Exception:
|
|
|
|
|
- errors += 1
|
|
|
|
|
- status, matrix = "错误", None
|
|
|
|
|
-
|
|
|
|
|
total += 1
|
|
total += 1
|
|
|
-
|
|
|
|
|
|
|
+ status, matrix = resolved[file_id]
|
|
|
if status == "ok":
|
|
if status == "ok":
|
|
|
- candidates.append((file_id, part, matrix))
|
|
|
|
|
|
|
+ candidates.append((file_id, part_of[file_id], matrix))
|
|
|
elif status == "无周期":
|
|
elif status == "无周期":
|
|
|
stats["无周期"] += 1
|
|
stats["无周期"] += 1
|
|
|
write_buffer.append((file_id, -1))
|
|
write_buffer.append((file_id, -1))
|