Add calibration summary export
This commit is contained in:
@@ -571,7 +571,17 @@ export function DetectionLab({
|
||||
<h3>Calibration run progress</h3>
|
||||
<p className="muted">Each row is backed by a persisted detection run and QA check when successful.</p>
|
||||
</div>
|
||||
<span className="count-pill">{detectionCalibrationRows.length} thresholds</span>
|
||||
<div className="panel-action-row">
|
||||
<span className="count-pill">{detectionCalibrationRows.length} thresholds</span>
|
||||
<button
|
||||
className="secondary-action"
|
||||
type="button"
|
||||
onClick={() => downloadCalibrationSummary(selectedProjectId, detectionCalibrationRows)}
|
||||
disabled={detectionCalibrationRows.length === 0 || !selectedProjectId}
|
||||
>
|
||||
Download calibration summary
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-scroll">
|
||||
<table>
|
||||
@@ -940,6 +950,51 @@ function stringFromRecord(record: Record<string, unknown> | null | undefined, ke
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value : null
|
||||
}
|
||||
|
||||
function buildCalibrationSummaryExport(projectId: string, rows: DetectionCalibrationRunRow[]): Record<string, unknown> {
|
||||
const successfulRows = rows.filter((row) => row.status === 'success')
|
||||
return {
|
||||
export_type: 'detection_calibration_summary',
|
||||
project_id: projectId,
|
||||
created_at: new Date().toISOString(),
|
||||
calibration_thresholds: rows.map((row) => row.threshold),
|
||||
quality_check_ids: successfulRows.map((row) => row.quality_check_id).filter(Boolean),
|
||||
rows: rows.map((row) => ({
|
||||
threshold: row.threshold,
|
||||
status: row.status,
|
||||
analysis_run_id: row.analysis_run_id ?? null,
|
||||
job_id: row.job_id ?? null,
|
||||
quality_check_id: row.quality_check_id ?? null,
|
||||
evidence_geojson_url: row.quality_check_id
|
||||
? `/api/v1/projects/${projectId}/quality-checks/${row.quality_check_id}/evidence/geojson`
|
||||
: null,
|
||||
detection_count: row.detection_count ?? null,
|
||||
precision: row.precision ?? null,
|
||||
recall: row.recall ?? null,
|
||||
f1_score: row.f1_score ?? null,
|
||||
false_positives: row.false_positives ?? null,
|
||||
false_negatives: row.false_negatives ?? null,
|
||||
message: row.message ?? null,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function downloadCalibrationSummary(projectId: string | null, rows: DetectionCalibrationRunRow[]): void {
|
||||
if (!projectId || rows.length === 0) {
|
||||
return
|
||||
}
|
||||
downloadJsonFile('detection-calibration-summary.json', buildCalibrationSummaryExport(projectId, rows))
|
||||
}
|
||||
|
||||
function downloadJsonFile(filename: string, payload: unknown): void {
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function formatNullableNumber(value: number | null, digits: number): string {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value.toFixed(digits) : 'n/a'
|
||||
}
|
||||
|
||||
@@ -3315,6 +3315,14 @@ button.entity-card {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.panel-action-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.calibration-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
|
||||
Reference in New Issue
Block a user