Files
micarrsim/packages/contracts/validation.js
T

621 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { validateDocumentStructure } from "./schema-validator.js";
export const SCHEMA_VERSION = "1.0.0";
const MAX_MICROPHONES = 1024;
const MAX_SOURCES = 32;
const MAX_GRID_SIDE = 512;
const MAX_FREQUENCY_RESPONSE_POINTS = 64;
const MAX_FREQUENCY_RESPONSE_HZ = 1_000_000;
const LOCALIZATION_ALGORITHMS = ["das", "das-phat", "gcc-phat"];
/** @typedef {{ path: string, message: string }} ValidationIssue */
function isRecord(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function isFiniteNumber(value) {
return typeof value === "number" && Number.isFinite(value);
}
function addIssue(issues, path, message) {
issues.push({ path, message });
}
function requireRecord(value, path, issues) {
if (!isRecord(value)) {
addIssue(issues, path, "객체여야 합니다.");
return false;
}
return true;
}
function requireString(value, path, issues) {
if (typeof value !== "string" || value.trim() === "") {
addIssue(issues, path, "비어 있지 않은 문자열이어야 합니다.");
return false;
}
return true;
}
function requireFinite(value, path, issues, options = {}) {
const { min = -Infinity, max = Infinity, exclusiveMin = false } = options;
if (!isFiniteNumber(value)) {
addIssue(issues, path, "유한한 숫자여야 합니다.");
return false;
}
if ((exclusiveMin && value <= min) || (!exclusiveMin && value < min) || value > max) {
addIssue(issues, path, `${exclusiveMin ? ">" : "≥"} ${min}, ≤ ${max} 범위여야 합니다.`);
return false;
}
return true;
}
function requireInteger(value, path, issues, options = {}) {
if (!Number.isInteger(value)) {
addIssue(issues, path, "정수여야 합니다.");
return false;
}
return requireFinite(value, path, issues, options);
}
function requirePosition(value, path, issues) {
if (!Array.isArray(value) || value.length !== 3) {
addIssue(issues, path, "[x, y, z] 숫자 3개여야 합니다.");
return false;
}
value.forEach((coordinate, index) => requireFinite(coordinate, `${path}[${index}]`, issues));
return true;
}
function requireBounds(value, path, issues) {
if (!requireRecord(value, path, issues)) return false;
const minimumValid = requirePosition(value.min, path + ".min", issues);
const maximumValid = requirePosition(value.max, path + ".max", issues);
if (!minimumValid || !maximumValid) return false;
let ordered = true;
for (let axis = 0; axis < 3; axis += 1) {
if (value.min[axis] >= value.max[axis]) {
addIssue(issues, path, "min 좌표는 모든 축에서 max 좌표보다 작아야 합니다.");
ordered = false;
break;
}
}
return ordered;
}
function boundsContain(bounds, position) {
return position.every(
(coordinate, axis) => coordinate >= bounds.min[axis] && coordinate <= bounds.max[axis],
);
}
function validateHeader(value, expectedKind, issues) {
if (!requireRecord(value, "$", issues)) return false;
if (value.schemaVersion !== SCHEMA_VERSION) {
addIssue(issues, "$.schemaVersion", `지원 버전은 ${SCHEMA_VERSION}입니다.`);
}
if (value.kind !== expectedKind) addIssue(issues, "$.kind", `${expectedKind}이어야 합니다.`);
requireString(value.id, "$.id", issues);
requireString(value.name, "$.name", issues);
return true;
}
function findDuplicates(values) {
const seen = new Set();
const duplicates = new Set();
for (const value of values) {
if (seen.has(value)) duplicates.add(value);
seen.add(value);
}
return [...duplicates];
}
function isSafeRelativeAssetPath(value) {
if (typeof value !== "string" || value.trim() === "") return false;
const normalized = value.split(String.fromCharCode(92)).join("/");
return !normalized.startsWith("/") &&
!/^[a-z][a-z0-9+.-]*:/i.test(normalized) &&
!normalized.split("/").includes("..") &&
!normalized.includes("\0");
}
function validateArrayRelationships(value) {
/** @type {ValidationIssue[]} */
const issues = [];
if (!validateHeader(value, "micarrsim.array", issues)) return issues;
if (value.units !== "m") addIssue(issues, "$.units", "m이어야 합니다.");
if (requireRecord(value.coordinateSystem, "$.coordinateSystem", issues)) {
const expected = { handedness: "right", xAxis: "right", yAxis: "up", zAxis: "toward-scene" };
for (const [key, expectedValue] of Object.entries(expected)) {
if (value.coordinateSystem[key] !== expectedValue) {
addIssue(issues, `$.coordinateSystem.${key}`, `${expectedValue}이어야 합니다.`);
}
}
}
if (requireRecord(value.aperture, "$.aperture", issues)) {
requireFinite(value.aperture.width, "$.aperture.width", issues, { min: 0, max: 20, exclusiveMin: true });
requireFinite(value.aperture.height, "$.aperture.height", issues, { min: 0, max: 20, exclusiveMin: true });
}
if (!Array.isArray(value.microphones) || value.microphones.length === 0) {
addIssue(issues, "$.microphones", "마이크가 하나 이상 필요합니다.");
return issues;
}
if (value.microphones.length > MAX_MICROPHONES) {
addIssue(issues, "$.microphones", `마이크는 최대 ${MAX_MICROPHONES}개까지 지원합니다.`);
}
const ids = [];
value.microphones.forEach((microphone, index) => {
const path = `$.microphones[${index}]`;
if (!requireRecord(microphone, path, issues)) return;
if (requireString(microphone.id, `${path}.id`, issues)) ids.push(microphone.id);
requirePosition(microphone.position, `${path}.position`, issues);
if (microphone.sensitivityDb !== undefined) {
requireFinite(microphone.sensitivityDb, `${path}.sensitivityDb`, issues, { min: -120, max: 120 });
}
if (microphone.frequencyResponse !== undefined) {
const responsePath = `${path}.frequencyResponse`;
if (!Array.isArray(microphone.frequencyResponse)) {
addIssue(issues, responsePath, "주파수 응답점 배열이어야 합니다.");
} else {
if (microphone.frequencyResponse.length < 2 || microphone.frequencyResponse.length > MAX_FREQUENCY_RESPONSE_POINTS) {
addIssue(issues, responsePath, `주파수 응답점은 2~${MAX_FREQUENCY_RESPONSE_POINTS}개여야 합니다.`);
}
let previousFrequency = -Infinity;
microphone.frequencyResponse.forEach((point, pointIndex) => {
const pointPath = `${responsePath}[${pointIndex}]`;
if (!requireRecord(point, pointPath, issues)) return;
const frequencyValid = requireFinite(point.frequencyHz, `${pointPath}.frequencyHz`, issues, {
min: 0,
max: MAX_FREQUENCY_RESPONSE_HZ,
});
requireFinite(point.gainDb, `${pointPath}.gainDb`, issues, { min: -120, max: 120 });
if (point.phaseDeg !== undefined) {
requireFinite(point.phaseDeg, `${pointPath}.phaseDeg`, issues, { min: -180, max: 180 });
}
if (frequencyValid && point.frequencyHz <= previousFrequency) {
addIssue(
issues,
`${pointPath}.frequencyHz`,
"주파수 응답점은 frequencyHz가 중복 없이 엄격히 증가하도록 정렬하세요.",
);
}
if (frequencyValid) previousFrequency = point.frequencyHz;
});
}
}
});
for (const duplicate of findDuplicates(ids)) {
addIssue(issues, "$.microphones", `중복 마이크 id: ${duplicate}`);
}
return issues;
}
function validateSignal(signal, path, issues, assetsById, sampleRate) {
if (!requireRecord(signal, path, issues)) return;
if (signal.type === "tone") {
if (requireFinite(signal.frequency, `${path}.frequency`, issues, { min: 0, exclusiveMin: true })) {
if (signal.frequency >= sampleRate / 2) {
addIssue(issues, `${path}.frequency`, `Nyquist 주파수 ${sampleRate / 2} Hz보다 낮아야 합니다.`);
}
}
if (signal.phaseDeg !== undefined) {
requireFinite(signal.phaseDeg, `${path}.phaseDeg`, issues, { min: -360, max: 360 });
}
} else if (signal.type === "noise") {
if (!["white", "pink"].includes(signal.color)) {
addIssue(issues, `${path}.color`, "white 또는 pink여야 합니다.");
}
} else if (signal.type === "asset") {
if (requireString(signal.assetId, `${path}.assetId`, issues)) {
const asset = assetsById.get(signal.assetId);
if (!asset) addIssue(issues, `${path}.assetId`, `정의되지 않은 asset: ${signal.assetId}`);
else if (asset.kind !== "audio") addIssue(issues, `${path}.assetId`, "audio asset을 참조해야 합니다.");
}
} else {
addIssue(issues, `${path}.type`, "tone, noise 또는 asset이어야 합니다.");
}
}
function validateSceneRelationships(value) {
/** @type {ValidationIssue[]} */
const issues = [];
if (!validateHeader(value, "micarrsim.scene", issues)) return issues;
if (value.units !== "m") addIssue(issues, "$.units", "m이어야 합니다.");
const sampleRateValid = requireInteger(value.sampleRate, "$.sampleRate", issues, { min: 8000, max: 192000 });
const sceneDurationValid = requireFinite(value.duration, "$.duration", issues, {
min: 0,
max: 300,
exclusiveMin: true,
});
requireFinite(value.speedOfSound, "$.speedOfSound", issues, { min: 250, max: 400 });
requireInteger(value.seed, "$.seed", issues, { min: 0, max: 0xffffffff });
let acousticRoom = null;
if (value.acoustics !== undefined && requireRecord(value.acoustics, "$.acoustics", issues)) {
const acoustics = value.acoustics;
if (acoustics.model === "free-field") {
const unusedFields = ["room", "reflections", "diffuseReverb", "occluders"]
.filter((key) => acoustics[key] !== undefined);
if (unusedFields.length > 0) {
addIssue(
issues,
"$.acoustics",
"free-field 모델에서는 사용되지 않는 설정을 제거하세요: " + unusedFields.join(", "),
);
}
} else if (acoustics.model === "shoebox") {
if (acoustics.room === undefined) {
addIssue(issues, "$.acoustics.room", "shoebox 모델에는 room 경계가 필요합니다.");
} else if (requireBounds(acoustics.room, "$.acoustics.room", issues)) {
acousticRoom = acoustics.room;
}
}
if (Array.isArray(acoustics.occluders)) {
const occluderIds = [];
acoustics.occluders.forEach((occluder, index) => {
const path = "$.acoustics.occluders[" + index + "]";
if (!requireRecord(occluder, path, issues)) return;
if (requireString(occluder.id, path + ".id", issues)) occluderIds.push(occluder.id);
if (requireBounds(occluder, path, issues) && acousticRoom) {
if (!boundsContain(acousticRoom, occluder.min) || !boundsContain(acousticRoom, occluder.max)) {
addIssue(issues, path, "occluder는 room 경계 안에 있어야 합니다.");
}
}
});
for (const duplicate of findDuplicates(occluderIds)) {
addIssue(issues, "$.acoustics.occluders", "중복 occluder id: " + duplicate);
}
}
}
const assets = Array.isArray(value.assets) ? value.assets : [];
const assetsById = new Map();
const allIds = [];
assets.forEach((asset, index) => {
const path = `$.assets[${index}]`;
if (!requireRecord(asset, path, issues)) return;
if (requireString(asset.id, `${path}.id`, issues)) {
assetsById.set(asset.id, asset);
allIds.push(asset.id);
}
if (requireString(asset.fileName, `${path}.fileName`, issues) && !isSafeRelativeAssetPath(asset.fileName)) {
addIssue(issues, `${path}.fileName`, "manifest 기준의 안전한 상대 경로여야 합니다.");
}
requireString(asset.mimeType, `${path}.mimeType`, issues);
if (!["audio", "image"].includes(asset.kind)) addIssue(issues, `${path}.kind`, "audio 또는 image여야 합니다.");
});
for (const duplicate of findDuplicates(allIds)) addIssue(issues, "$.assets", `중복 asset id: ${duplicate}`);
if (value.image !== undefined && requireRecord(value.image, "$.image", issues)) {
if (requireString(value.image.assetId, "$.image.assetId", issues)) {
const asset = assetsById.get(value.image.assetId);
if (!asset) addIssue(issues, "$.image.assetId", `정의되지 않은 asset: ${value.image.assetId}`);
else if (asset.kind !== "image") addIssue(issues, "$.image.assetId", "image asset을 참조해야 합니다.");
}
if (requireRecord(value.image.plane, "$.image.plane", issues)) {
const plane = value.image.plane;
requireFinite(plane.z, "$.image.plane.z", issues, { min: 0, exclusiveMin: true });
for (const key of ["xMin", "xMax", "yMin", "yMax"]) {
requireFinite(plane[key], `$.image.plane.${key}`, issues);
}
if (isFiniteNumber(plane.xMin) && isFiniteNumber(plane.xMax) && plane.xMin >= plane.xMax) {
addIssue(issues, "$.image.plane", "xMin은 xMax보다 작아야 합니다.");
}
if (isFiniteNumber(plane.yMin) && isFiniteNumber(plane.yMax) && plane.yMin >= plane.yMax) {
addIssue(issues, "$.image.plane", "yMin은 yMax보다 작아야 합니다.");
}
}
}
if (!Array.isArray(value.sources) || value.sources.length === 0) {
addIssue(issues, "$.sources", "음원이 하나 이상 필요합니다.");
return issues;
}
if (value.sources.length > MAX_SOURCES) {
addIssue(issues, "$.sources", `음원은 최대 ${MAX_SOURCES}개까지 지원합니다.`);
}
const sourceIds = [];
value.sources.forEach((source, index) => {
const path = `$.sources[${index}]`;
if (!requireRecord(source, path, issues)) return;
if (requireString(source.id, `${path}.id`, issues)) sourceIds.push(source.id);
requireString(source.name, `${path}.name`, issues);
const sourcePositionValid = requirePosition(source.position, `${path}.position`, issues);
if (sourcePositionValid && acousticRoom && !boundsContain(acousticRoom, source.position)) {
addIssue(issues, `${path}.position`, "shoebox room 경계 안에 있어야 합니다.");
}
if (Array.isArray(source.trajectory)) {
let previousTime = -Infinity;
source.trajectory.forEach((keyframe, keyframeIndex) => {
const keyframePath = `${path}.trajectory[${keyframeIndex}]`;
if (!requireRecord(keyframe, keyframePath, issues)) return;
const timeValid = requireFinite(keyframe.time, `${keyframePath}.time`, issues, {
min: 0,
max: sceneDurationValid ? value.duration : 300,
});
const keyframePositionValid = requirePosition(keyframe.position, `${keyframePath}.position`, issues);
if (keyframePositionValid && acousticRoom && !boundsContain(acousticRoom, keyframe.position)) {
addIssue(issues, `${keyframePath}.position`, "shoebox room 경계 안에 있어야 합니다.");
}
if (timeValid && keyframe.time <= previousTime) {
addIssue(issues, `${keyframePath}.time`, "trajectory time은 앞 keyframe보다 커야 합니다.");
}
if (timeValid) previousTime = keyframe.time;
});
}
requireFinite(source.gainDb, `${path}.gainDb`, issues, { min: -120, max: 24 });
const startValid = requireFinite(source.startTime, `${path}.startTime`, issues, { min: 0 });
const durationValid = requireFinite(source.duration, `${path}.duration`, issues, {
min: 0,
max: 300,
exclusiveMin: true,
});
if (sceneDurationValid && startValid && durationValid && source.startTime + source.duration > value.duration + 1e-9) {
addIssue(issues, path, "음원 구간이 장면 duration을 벗어납니다.");
}
validateSignal(source.signal, `${path}.signal`, issues, assetsById, sampleRateValid ? value.sampleRate : 48000);
});
for (const duplicate of findDuplicates(sourceIds)) addIssue(issues, "$.sources", `중복 음원 id: ${duplicate}`);
if (Array.isArray(value.ambient)) {
const ambientIds = [];
value.ambient.forEach((ambient, index) => {
const path = `$.ambient[${index}]`;
if (!requireRecord(ambient, path, issues)) return;
if (requireString(ambient.id, `${path}.id`, issues)) ambientIds.push(ambient.id);
requireFinite(ambient.gainDb, `${path}.gainDb`, issues, { min: -120, max: 24 });
validateSignal(ambient.signal, `${path}.signal`, issues, assetsById, sampleRateValid ? value.sampleRate : 48000);
});
for (const duplicate of findDuplicates(ambientIds)) addIssue(issues, "$.ambient", `중복 ambient id: ${duplicate}`);
}
return issues;
}
function validateExperimentRelationships(value, context = {}) {
/** @type {ValidationIssue[]} */
const issues = [];
if (!validateHeader(value, "micarrsim.experiment", issues)) return issues;
requireString(value.arrayId, "$.arrayId", issues);
requireString(value.sceneId, "$.sceneId", issues);
if (context.arrayDefinition && value.arrayId !== context.arrayDefinition.id) {
addIssue(issues, "$.arrayId", "불러온 배열 id와 다릅니다.");
}
if (context.scene && value.sceneId !== context.scene.id) {
addIssue(issues, "$.sceneId", "불러온 장면 id와 다릅니다.");
}
if (requireRecord(value.scan, "$.scan", issues)) {
requireFinite(value.scan.z, "$.scan.z", issues, { min: 0, exclusiveMin: true });
for (const key of ["xMin", "xMax", "yMin", "yMax"]) {
requireFinite(value.scan[key], `$.scan.${key}`, issues);
}
requireInteger(value.scan.columns, "$.scan.columns", issues, { min: 8, max: MAX_GRID_SIDE });
requireInteger(value.scan.rows, "$.scan.rows", issues, { min: 8, max: MAX_GRID_SIDE });
if (isFiniteNumber(value.scan.xMin) && isFiniteNumber(value.scan.xMax) && value.scan.xMin >= value.scan.xMax) {
addIssue(issues, "$.scan", "xMin은 xMax보다 작아야 합니다.");
}
if (isFiniteNumber(value.scan.yMin) && isFiniteNumber(value.scan.yMax) && value.scan.yMin >= value.scan.yMax) {
addIssue(issues, "$.scan", "yMin은 yMax보다 작아야 합니다.");
}
}
if (!Array.isArray(value.frequencyBands) || value.frequencyBands.length === 0) {
addIssue(issues, "$.frequencyBands", "주파수 대역이 하나 이상 필요합니다.");
} else {
const bandIds = [];
value.frequencyBands.forEach((band, index) => {
const path = `$.frequencyBands[${index}]`;
if (!requireRecord(band, path, issues)) return;
if (requireString(band.id, `${path}.id`, issues)) bandIds.push(band.id);
const minValid = requireFinite(band.minHz, `${path}.minHz`, issues, { min: 0 });
const maxValid = requireFinite(band.maxHz, `${path}.maxHz`, issues, { min: 0, exclusiveMin: true });
if (minValid && maxValid && band.minHz >= band.maxHz) {
addIssue(issues, path, "minHz는 maxHz보다 작아야 합니다.");
}
if (context.scene && maxValid && band.maxHz >= context.scene.sampleRate / 2) {
addIssue(issues, `${path}.maxHz`, `Nyquist 주파수 ${context.scene.sampleRate / 2} Hz보다 낮아야 합니다.`);
}
if (typeof band.color !== "string" || !/^#[0-9a-f]{6}$/i.test(band.color)) {
addIssue(issues, `${path}.color`, "#RRGGBB 색상이어야 합니다.");
}
});
for (const duplicate of findDuplicates(bandIds)) addIssue(issues, "$.frequencyBands", `중복 band id: ${duplicate}`);
}
if (requireRecord(value.stft, "$.stft", issues)) {
if (![128, 256, 512, 1024, 2048, 4096].includes(value.stft.fftSize)) {
addIssue(issues, "$.stft.fftSize", "지원되는 2의 거듭제곱 크기가 아닙니다.");
}
if (!Number.isInteger(value.stft.hopSize) || value.stft.hopSize < 1 || value.stft.hopSize > value.stft.fftSize) {
addIssue(issues, "$.stft.hopSize", "1 이상 fftSize 이하의 정수여야 합니다.");
}
if (value.stft.window !== "hann") addIssue(issues, "$.stft.window", "hann이어야 합니다.");
}
if (requireRecord(value.beamforming, "$.beamforming", issues)) {
if (!LOCALIZATION_ALGORITHMS.includes(value.beamforming.algorithm)) {
addIssue(
issues,
"$.beamforming.algorithm",
"das, das-phat 또는 gcc-phat이어야 합니다.",
);
}
if (!["auto", "cpu", "webgpu"].includes(value.beamforming.backend)) {
addIssue(issues, "$.beamforming.backend", "auto, cpu 또는 webgpu여야 합니다.");
}
if (
value.beamforming.algorithm === "gcc-phat" &&
value.beamforming.backend === "webgpu"
) {
addIssue(
issues,
"$.beamforming.backend",
"gcc-phat는 현재 CPU backend만 지원합니다. auto 또는 cpu를 선택하세요.",
);
}
requireFinite(value.beamforming.minimumDistance, "$.beamforming.minimumDistance", issues, {
min: 0,
max: 10,
exclusiveMin: true,
});
if (
value.beamforming.compensateFrequencyResponse !== undefined &&
typeof value.beamforming.compensateFrequencyResponse !== "boolean"
) {
addIssue(
issues,
"$.beamforming.compensateFrequencyResponse",
"주파수 응답 보정 사용 여부는 true 또는 false여야 합니다.",
);
}
if (value.beamforming.maxCompensationDb !== undefined) {
requireFinite(
value.beamforming.maxCompensationDb,
"$.beamforming.maxCompensationDb",
issues,
{ min: 0, max: 120 },
);
}
}
if (value.evaluation !== undefined && requireRecord(value.evaluation, "$.evaluation", issues)) {
const threshold = value.evaluation.detectionThresholdRatio;
if (!isFiniteNumber(threshold) || threshold <= 0 || threshold > 1) {
addIssue(
issues,
"$.evaluation.detectionThresholdRatio",
"피크 검출 임계값 비율은 0보다 크고 1 이하여야 합니다. 더 약한 피크를 찾으려면 값을 낮추세요.",
);
}
}
if (context.arrayDefinition && context.scene) {
if (
value.beamforming?.algorithm === "gcc-phat" &&
context.arrayDefinition.microphones.length < 2
) {
addIssue(
issues,
"$.beamforming.algorithm",
"gcc-phat에는 서로 다른 두 개 이상의 마이크가 필요합니다.",
);
}
const retainedBufferCount = context.arrayDefinition.microphones.length + context.scene.sources.length + 1;
const sampleCount = Math.ceil(context.scene.duration * context.scene.sampleRate);
let maximumDelaySamples = 0;
for (const source of context.scene.sources) {
const sourcePositions = Array.isArray(source.trajectory)
? source.trajectory.map((keyframe) => keyframe.position)
: [source.position];
for (const sourcePosition of sourcePositions) {
for (const microphone of context.arrayDefinition.microphones) {
const distance = Math.hypot(
sourcePosition[0] - microphone.position[0],
sourcePosition[1] - microphone.position[1],
sourcePosition[2] - microphone.position[2],
);
maximumDelaySamples = Math.max(
maximumDelaySamples,
(distance / context.scene.speedOfSound) * context.scene.sampleRate,
);
}
}
}
let delayFftSize = 1;
while (delayFftSize < sampleCount + Math.ceil(maximumDelaySamples) + 2) delayFftSize *= 2;
const estimatedBytes = retainedBufferCount * sampleCount * 4 + delayFftSize * 8 * 4;
const maximumBytes = 512 * 1024 * 1024;
if (estimatedBytes > maximumBytes) {
addIssue(
issues,
"$",
`현재 비스트리밍 CPU 모델의 signal buffer 예상 크기 ${(estimatedBytes / 1024 ** 2).toFixed(1)} MiB가 512 MiB 한도를 넘습니다.`,
);
}
}
return issues;
}
function validateResultRelationships(value, context = {}) {
/** @type {ValidationIssue[]} */
const issues = [];
if (!isRecord(value)) return issues;
if (context.experiment) {
if (value.experimentId !== context.experiment.id) addIssue(issues, "$.experimentId", "불러온 실험 id와 다릅니다.");
if (value.arrayId !== context.experiment.arrayId) addIssue(issues, "$.arrayId", "실험의 배열 id와 다릅니다.");
if (value.sceneId !== context.experiment.sceneId) addIssue(issues, "$.sceneId", "실험의 장면 id와 다릅니다.");
if (
value.algorithm !== undefined &&
value.algorithm !== context.experiment.beamforming?.algorithm
) {
addIssue(issues, "$.algorithm", "실험의 위치 추정 알고리즘과 다릅니다.");
}
}
const sourceIds = new Set(context.scene?.sources?.map((source) => source.id) ?? []);
const expectedBandIds = new Set(context.experiment?.frequencyBands?.map((band) => band.id) ?? []);
const bandIds = [];
if (Array.isArray(value.bands)) {
value.bands.forEach((band, bandIndex) => {
if (!isRecord(band)) return;
const path = `$.bands[${bandIndex}]`;
if (typeof band.id === "string") {
bandIds.push(band.id);
if (expectedBandIds.size > 0 && !expectedBandIds.has(band.id)) addIssue(issues, `${path}.id`, "실험에 없는 band id입니다.");
}
const maps = [band.rawMap, band.displayMap, ...(band.processedMaps?.map((item) => item.map) ?? [])].filter(Boolean);
maps.forEach((map, mapIndex) => {
if (!isRecord(map) || !Array.isArray(map.values)) return;
if (Number.isInteger(map.rows) && Number.isInteger(map.columns) && map.values.length !== map.rows * map.columns) {
addIssue(issues, `${path}.maps[${mapIndex}].values`, "values 길이는 rows × columns와 같아야 합니다.");
}
if (context.experiment && (map.rows !== context.experiment.scan.rows || map.columns !== context.experiment.scan.columns)) {
addIssue(issues, `${path}.maps[${mapIndex}]`, "map shape이 실험 scan grid와 다릅니다.");
}
});
if (isRecord(band.rawMap) && !["power", "score"].includes(band.rawMap.scale)) {
addIssue(issues, `${path}.rawMap.scale`, "rawMap은 power 또는 score scale이어야 합니다.");
}
if (Array.isArray(band.peaks)) {
band.peaks.forEach((peak, peakIndex) => {
if (peak?.sourceId && sourceIds.size > 0 && !sourceIds.has(peak.sourceId)) {
addIssue(issues, `${path}.peaks[${peakIndex}].sourceId`, "장면에 없는 source id입니다.");
}
});
}
});
}
for (const duplicate of findDuplicates(bandIds)) addIssue(issues, "$.bands", `중복 result band id: ${duplicate}`);
return issues;
}
async function combineValidation(name, value, relationshipIssues) {
return [...(await validateDocumentStructure(name, value)), ...relationshipIssues];
}
export async function validateArrayDefinition(value) {
return combineValidation("array", value, validateArrayRelationships(value));
}
export async function validateScene(value) {
return combineValidation("scene", value, validateSceneRelationships(value));
}
export async function validateExperiment(value, context = {}) {
return combineValidation("experiment", value, validateExperimentRelationships(value, context));
}
export async function validateResult(value, context = {}) {
return combineValidation("result", value, validateResultRelationships(value, context));
}
export async function assertValid(value, validator, label = "데이터") {
const issues = await validator(value);
if (issues.length > 0) {
const details = issues.map(({ path, message }) => `${path}: ${message}`).join("\n");
throw new Error(`${label} 검증 실패\n${details}`);
}
return value;
}