script-volume-auto/src/runs/runs.service.ts
Nicolas Fryder 79daa30a8a Implementation initiale : calcul de volume 2.5D CloudCompare et test de robustesse par decimation spatiale progressive
- Backend NestJS orchestrant CloudCompare CLI (stats, decimation x2 sur 5 etapes, volume, assemblage .bin)
- Scripts Python (laspy, scipy, matplotlib) pour la conversion LAS/LAZ et la generation d'images
- Frontend web simple (Tailwind CDN) avec historique des runs
- Image Docker (debian:trixie-slim + cloudcompare apt + xvfb) prete pour deploiement Coolify
- Lint/typecheck/tests unitaires integres comme portes de qualite au build Docker
- CLAUDE.md documentant les comportements CloudCompare CLI verifies empiriquement
2026-07-10 13:55:25 +02:00

167 lines
4.9 KiB
TypeScript

import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import * as fs from 'fs/promises';
import * as path from 'path';
import { v4 as uuidv4 } from 'uuid';
import { resultsDir, uploadsDir } from '../config';
import { PipelineService } from '../pipeline/pipeline.service';
import { RunParamsInput } from '../pipeline/types';
import { DatabaseService, RunRow } from './database.service';
export interface CreateRunOptions {
tmpFilePath: string;
originalFilename: string;
params: RunParamsInput;
}
interface QueueJob {
id: string;
inputPath: string;
originalFilename: string;
params: RunParamsInput;
}
@Injectable()
export class RunsService {
private readonly logger = new Logger(RunsService.name);
private queue: QueueJob[] = [];
private processing = false;
constructor(
private readonly db: DatabaseService,
private readonly pipeline: PipelineService,
) {}
async createRun(opts: CreateRunOptions): Promise<{ id: string }> {
const id = uuidv4();
const runUploadDir = path.join(uploadsDir(), id);
await fs.mkdir(runUploadDir, { recursive: true });
const ext = this.extensionOf(opts.originalFilename);
const destPath = path.join(runUploadDir, `input${ext}`);
await fs.rename(opts.tmpFilePath, destPath);
this.db.insert({
id,
original_filename: opts.originalFilename,
status: 'pending',
created_at: new Date().toISOString(),
params_json: JSON.stringify(opts.params),
});
this.queue.push({ id, inputPath: destPath, originalFilename: opts.originalFilename, params: opts.params });
void this.processQueue();
return { id };
}
private async processQueue() {
if (this.processing) return;
this.processing = true;
try {
while (this.queue.length > 0) {
const job = this.queue.shift()!;
await this.runJob(job);
}
} finally {
this.processing = false;
}
}
private async runJob(job: QueueJob) {
this.db.updateStatus(job.id, 'running');
const workDir = path.join(resultsDir(), job.id);
await fs.mkdir(workDir, { recursive: true });
const onProgress = (msg: string) => {
this.logger.log(`[${job.id}] ${msg}`);
this.db.appendLog(job.id, msg);
};
try {
const report = await this.pipeline.execute(job.id, job.inputPath, job.originalFilename, workDir, job.params, onProgress);
this.db.complete(job.id, JSON.stringify(report));
onProgress('Run termine avec succes.');
} catch (err: any) {
this.logger.error(`Run ${job.id} en erreur: ${err.message}`);
this.db.appendLog(job.id, `ERREUR FATALE: ${err.message}`);
this.db.fail(job.id, err.message);
}
}
list() {
return this.db.list().map((r) => this.toSummary(r));
}
getDetail(id: string) {
const row = this.db.get(id);
if (!row) throw new NotFoundException(`Run ${id} introuvable`);
return {
id: row.id,
originalFilename: row.original_filename,
status: row.status,
createdAt: row.created_at,
finishedAt: row.finished_at,
params: row.params_json ? JSON.parse(row.params_json) : null,
report: row.report_json ? JSON.parse(row.report_json) : null,
log: row.log,
error: row.error,
};
}
getRowOrThrow(id: string): RunRow {
const row = this.db.get(id);
if (!row) throw new NotFoundException(`Run ${id} introuvable`);
return row;
}
workDirFor(id: string): string {
return path.join(resultsDir(), id);
}
async delete(id: string) {
this.getRowOrThrow(id); // leve NotFoundException si le run n'existe pas
this.db.remove(id);
const work = this.workDirFor(id);
const upload = path.join(uploadsDir(), id);
await fs.rm(work, { recursive: true, force: true });
await fs.rm(upload, { recursive: true, force: true });
return { id };
}
private toSummary(row: RunRow) {
let summary: any = null;
if (row.report_json) {
try {
const report = JSON.parse(row.report_json);
const okLevels = (report.levels ?? []).filter((l: any) => l.status === 'ok');
const first = okLevels[0];
const last = okLevels[okLevels.length - 1];
summary = {
levelsOk: okLevels.length,
levelsTotal: report.levels?.length ?? 0,
volumeLevel0: first?.volume ?? null,
volumeLastLevel: last?.volume ?? null,
volumeDeltaPct: first && last && first.volume ? (100 * (last.volume - first.volume)) / first.volume : null,
};
} catch {
summary = null;
}
}
return {
id: row.id,
originalFilename: row.original_filename,
status: row.status,
createdAt: row.created_at,
finishedAt: row.finished_at,
error: row.error,
summary,
};
}
private extensionOf(filename: string): string {
const lower = filename.toLowerCase();
if (lower.endsWith('.copc.laz')) return '.copc.laz';
const idx = lower.lastIndexOf('.');
return idx >= 0 ? filename.slice(idx) : '';
}
}