feat(core): parse P1 workbook and build T-type pier shape

- parse Excel input by header strings (pier, IP, bearing tables)
- DMS azimuth parser (E03), input validation (E01, E02)
- pure geometry: axis, bearings, stepped coping, column, footing, piles, blinding
- warnings W01, W02, W05, W07, I01
- regression tests against spec.md section 12 (P1)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-10-21 17:26:16 +09:00
commit d963d8ed3c
15 files changed
+2527

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { parseDms } from '../src/core/dms';
describe('parseDms', () => {
it('converts the P1 sheet string to decimal degrees (spec §12: 75.9489°)', () => {
expect(parseDms(` 75°56'55.9"`)).toBeCloseTo(75.9489, 4);
});
it('returns null for malformed strings (E03)', () => {
expect(parseDms('75.9489')).toBeNull();
expect(parseDms(`75°61'00"`)).toBeNull();
});
});
+19
View File
@@ -0,0 +1,19 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import * as XLSX from 'xlsx';
import { parseWorkbook } from '../src/core/parseWorkbook';
import type { PierInput } from '../src/core/types';
const P1_PATH = fileURLToPath(new URL('../public/samples/P1.xlsx', import.meta.url));
export function loadWorkbook(path: string = P1_PATH): XLSX.WorkBook {
return XLSX.read(readFileSync(path), { type: 'buffer' });
}
export function loadP1(): PierInput {
const { piers, issues } = parseWorkbook(loadWorkbook());
if (issues.length) throw new Error(`P1 parse issues: ${JSON.stringify(issues)}`);
const p1 = piers.find((p) => p.name === 'P1');
if (!p1) throw new Error('P1 not found');
return p1;
}
+102
View File
@@ -0,0 +1,102 @@
import { describe, expect, it } from 'vitest';
import { buildPier } from '../src/core/geometry';
import { loadP1 } from './helpers';
/**
* Regression against spec.md §12 (P1, 운곡교, T형, 말뚝기초).
* Coordinates and EL: ±1 mm (0.001 m). Angles: ±0.01°.
*/
const TOL = 0.001;
const model = buildPier(loadP1());
describe('P1 regression — spec.md §12', () => {
it('axis length and azimuth', () => {
expect(model.lengthIp).toBeCloseTo(12.166, 3);
expect(model.azimuthDeg).toBeCloseTo(350.37, 2);
});
it('BP1, BP2 coordinates (국토좌표 E, N)', () => {
expect(model.bp1.e).toBeCloseTo(259866.681, 2);
expect(model.bp1.n).toBeCloseTo(179132.173, 2);
expect(Math.abs(model.bp2.e - 259864.674)).toBeLessThan(TOL);
expect(Math.abs(model.bp2.n - 179144.004)).toBeLessThan(TOL);
});
it('IP2 reference recalculation and d_IP2', () => {
expect(Math.abs(model.dIp2 - 1.134)).toBeLessThan(TOL);
});
it('column centre coordinates', () => {
expect(Math.abs(model.pivot.e - 259865.677)).toBeLessThan(TOL);
expect(Math.abs(model.pivot.n - 179138.089)).toBeLessThan(TOL);
});
it('bearing positions: front row across-axis −0.698, back row +0.600; front s from 0.943 to 10.965', () => {
const front = model.bearings.filter((b) => b.row === '전열');
const back = model.bearings.filter((b) => b.row === '후열');
expect(front[0].n).toBeCloseTo(-0.698, 3);
expect(back[0].n).toBeCloseTo(0.6, 3);
expect(front[0].s).toBeCloseTo(0.943, 3);
expect(front[4].s).toBeCloseTo(10.965, 3);
expect(back[0].s).toBeCloseTo(1.029, 3);
expect(back[4].s).toBeCloseTo(11.05, 3);
});
it('step boundaries at the midpoints of front bearings: 2.196 / 4.701 / 7.207 / 9.712', () => {
const bounds = model.segments.slice(1).map((s) => s.s0);
[2.196, 4.701, 7.207, 9.712].forEach((b, i) => expect(Math.abs(bounds[i] - b)).toBeLessThan(TOL));
});
it('front segment tops: 43.411 / 43.483 / 43.555 / 43.626 / 43.698', () => {
const tops = model.segments.map((s) => s.top);
[43.411, 43.483, 43.555, 43.626, 43.698].forEach((t, i) => expect(Math.abs(tops[i] - t)).toBeLessThan(TOL));
});
it('bearing block heights: h1 = 150, h2 = 200 mm; concrete 100 / 150 mm', () => {
expect(model.h1).toBeCloseTo(0.15, 6);
expect(model.h2).toBeCloseTo(0.2, 6);
expect(model.derived.blockHeightFront).toBeCloseTo(0.1, 6);
expect(model.derived.blockHeightBack).toBeCloseTo(0.15, 6);
});
it('no lateral step (front and back tops agree)', () => {
expect(model.derived.frontBackDiffSpread).toBeLessThan(0.0051);
});
it('coping bottom: 42.411 at the ends, 40.911 at the Level zone', () => {
expect(Math.abs(model.endBottom - 42.411)).toBeLessThan(TOL);
expect(Math.abs(model.levelBottom - 40.911)).toBeLessThan(TOL);
});
it('column height 12.703 m and footing top 28.208 m, soil cover 0.723 m', () => {
expect(Math.abs(model.derived.columnHeight - 12.703)).toBeLessThan(TOL);
expect(Math.abs(model.footings[0].top - 28.208)).toBeLessThan(TOL);
expect(Math.abs(model.derived.soilCover - 0.723)).toBeLessThan(TOL);
});
it('piles: 4 rows × 5 = 20, spacing 2250 mm, tip at 2.208 m', () => {
expect(model.piles).toHaveLength(20);
expect(model.derived.pileCount).toBe(20);
expect(Math.abs((model.derived.pileTipEl ?? NaN) - 2.208)).toBeLessThan(TOL);
const sValues = [...new Set(model.piles.map((p) => p.s.toFixed(4)))];
const nValues = [...new Set(model.piles.map((p) => p.n.toFixed(4)))];
expect(sValues).toHaveLength(5);
expect(nValues).toHaveLength(4);
expect(Number(nValues[1]) - Number(nValues[0])).toBeCloseTo(2.25, 4);
expect(Number(sValues[1]) - Number(sValues[0])).toBeCloseTo(2.25, 4);
});
it('blinding concrete 10600 × 8350 × 100 under the footing', () => {
const b = model.blindings[0];
expect(b.length).toBeCloseTo(10.6, 4);
expect(b.width).toBeCloseTo(8.35, 4);
expect(b.thickness).toBeCloseTo(0.1, 4);
});
it('footing 10400 × 8150 × 2000 mm', () => {
const f = model.footings[0];
expect(f.length).toBeCloseTo(10.4, 4);
expect(f.width).toBeCloseTo(8.15, 4);
expect(f.top - f.bottom).toBeCloseTo(2.0, 4);
});
});
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { parseWorkbook } from '../src/core/parseWorkbook';
import { loadP1, loadWorkbook } from './helpers';
describe('parseWorkbook (P1 sheet intent.PR)', () => {
it('finds exactly one pier P1 with the input values of the sheet', () => {
const { piers, issues } = parseWorkbook(loadWorkbook());
expect(issues).toEqual([]);
expect(piers.map((p) => p.name)).toEqual(['P1']);
const p1 = loadP1();
expect(p1.type).toBe('T형');
expect(p1.foundationType).toBe('말뚝기초');
expect(p1.copingTopEl).toBeCloseTo(43.411, 6);
expect(p1.footingBottomEl).toBeCloseTo(26.208, 6);
expect(p1.groundEl).toBeCloseTo(28.931, 6);
expect(p1.pileLengthMm).toBe(24000);
expect(p1.copingWidthMm).toBe(3100);
expect(p1.copingLengthMm).toBe(12000);
expect(p1.columnCount).toBe(1);
});
it('reads 4 IP points and 10 bearings (5 front, 5 back)', () => {
const p1 = loadP1();
expect(p1.ips.map((p) => p.name)).toEqual(['IP1', 'IP2', 'IP3', 'IP4']);
expect(p1.bearings).toHaveLength(10);
expect(p1.bearings.filter((b) => b.row === '전열')).toHaveLength(5);
expect(p1.bearings.filter((b) => b.row === '후열')).toHaveLength(5);
});
it('converts the bearing azimuth strings to decimal degrees', () => {
const p1 = loadP1();
expect(p1.bearings[0].azimuthDeg).toBeCloseTo(75.9489, 4);
});
});
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import { buildPier } from '../src/core/geometry';
import { validateInput, validatePier } from '../src/core/validate';
import type { Issue, PierInput } from '../src/core/types';
import { loadP1 } from './helpers';
function run(input: PierInput): Issue[] {
const issues: Issue[] = [];
if (validateInput(input, issues)) validatePier(input, buildPier(input), issues);
return issues;
}
describe('validation (spec §10)', () => {
it('P1 raises I01 (block 100 mm < cover + bar 119 mm) and no E/W', () => {
const issues = run(loadP1());
expect(issues.map((i) => i.code)).toContain('I01');
expect(issues.filter((i) => i.level !== 'I')).toEqual([]);
});
it('E01 when the pier type and column count disagree', () => {
const issues = run({ ...loadP1(), type: '2주형' });
expect(issues.map((i) => i.code)).toContain('E01');
});
it('E02 when front and back bearing counts differ', () => {
const p1 = loadP1();
const issues: Issue[] = [];
const bad = { ...p1, bearings: p1.bearings.filter((b) => !(b.row === '후열' && b.no === 10)) };
expect(validateInput(bad, issues)).toBe(false);
expect(issues.map((i) => i.code)).toContain('E02');
});
it('W01 when IP2 disagrees with the 1:9 recalculation by more than 1 mm', () => {
const p1 = loadP1();
const ips = p1.ips.map((p) => (p.name === 'IP2' ? { ...p, x: p.x + 0.002 } : p));
expect(run({ ...p1, ips }).map((i) => i.code)).toContain('W01');
});
});
describe('foundation types', () => {
it('직접기초: footing, no piles', () => {
const m = buildPier({ ...loadP1(), foundationType: '직접기초' });
expect(m.footings).toHaveLength(1);
expect(m.piles).toHaveLength(0);
});
it('현장타설말뚝: no footing, one pile of column diameter per column, tip = bottom EL − length', () => {
const m = buildPier({ ...loadP1(), foundationType: '현장타설말뚝' });
expect(m.footings).toHaveLength(0);
expect(m.piles).toHaveLength(1);
expect(m.piles[0].diameter).toBeCloseTo(2.0, 6);
expect(m.piles[0].bottom).toBeCloseTo(26.208 - 24, 6);
});
});