feat(bearing): 교대만 있을 때 A1 ↔ A2 현(chord) 짝 받침
교각 없이 A1 · A2만 불러온 다경간 곡선교(운곡교 광주·완도, 358 m, 방위각 8° 차)는 거더 짝 허용오차 (방위각 ≤ 0.1°)를 넘어 짝이 없었다. 교각이 0기일 때만, A1 받침과 A2 받침을 잇는 현의 방향이 두 방위각의 평균과 0.1° 이내인 받침을 짝으로 한다(원곡선이면 정확히 같다). 같은 노선·같은 번호가 117″, 다른 후보는 1176″ 이상이라 하나로 정해진다. - S = Δz / 현 길이 (교량 전체 평균 경사), S 근거 '계산', 짝 받침 표시 "A2-광주-① (현)" - 방위각 검사 칸은 "현 117″ (참고)": 한 거더용 10″ 규칙이 아니므로 WS01은 내지 않음 - 교각이 하나라도 있으면 쓰지 않음. 단경간 직선(A1·A2 방위각 같음)은 기존 거더 짝으로 처리 - 테스트: 곡선 두 노선 합성 10쌍(노선·번호 일치), 교대 카드 표시, 교각 있을 때 미적용 - 브라우저: 운곡교AA.xlsx A1-광주 ①–⑤ ↔ A2-광주 ①–⑤ (현), S −1.900 ~ −1.892 % Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3bd2340609
commit
7c5f129712
7 files changed
+88
-8
No files matched your search
@@ -131,6 +131,7 @@ export interface AbutSolplateRow {
|
||||
pair?: string;
|
||||
/** 방위각 검사 (″): input azimuth against the pair direction; absent without a pair. */
|
||||
azimuthSec?: number;
|
||||
pairKind?: 'girder' | 'chord';
|
||||
L: number;
|
||||
W: number;
|
||||
T: number;
|
||||
@@ -146,7 +147,7 @@ export function abutSolplateRows(model: AbutModel, rule: AbutBearingRule, height
|
||||
const rhs = (p.L * Math.abs(p.slopePct)) / 100;
|
||||
return {
|
||||
no: p.no, S: p.slopePct, source: p.slopeSource, azimuthSec: p.azimuthCheckSec,
|
||||
pair: p.pair ? `${p.pair.structure}-${CIRCLED[p.pair.no - 1] ?? p.pair.no}` : undefined,
|
||||
pair: p.pair ? `${p.pair.structure}-${CIRCLED[p.pair.no - 1] ?? p.pair.no}${p.pairKind === 'chord' ? ' (현)' : ''}` : undefined, pairKind: p.pairKind,
|
||||
L: p.L, W: p.W, T: p.T, T1: p.T1, T2: p.T2,
|
||||
check: `${p.T1 - p.T2} ≈ ${rhs.toFixed(1)} ${Math.abs(p.T1 - p.T2 - rhs) <= 1 + 1e-9 ? '일치' : '불일치 (W08)'}`,
|
||||
warnings: p.warnings,
|
||||
|
||||
@@ -45,6 +45,8 @@ export interface BearingPair {
|
||||
slopePct: number;
|
||||
/** The structure on the higher end: the 오르막 쪽 where T1 sits for both bearings. */
|
||||
uphill: string;
|
||||
/** girder = the two ends of one girder; chord = A1 ↔ A2 of a bridge loaded without piers (현, chordPairs). */
|
||||
kind: 'girder' | 'chord';
|
||||
warnings: PairWarning[];
|
||||
}
|
||||
|
||||
@@ -87,13 +89,41 @@ export function girderPairs(structures: PairStructure[]): BearingPair[] {
|
||||
pairs.push({
|
||||
from: { structure: here.name, no: b.no }, to: { structure: best.c.structure, no: best.c.no },
|
||||
distanceM, offsetM: best.offset, azimuthDiffDeg: best.dAz, directionDeg, fromCheckSec, toCheckSec,
|
||||
slopePct, uphill: best.c.z > b.z ? best.c.structure : here.name, warnings,
|
||||
slopePct, uphill: best.c.z > b.z ? best.c.structure : here.name, warnings, kind: 'girder',
|
||||
});
|
||||
}
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
/**
|
||||
* A1 ↔ A2 pairs when no pier is loaded (교대만, 검토 2026-11-06). Over several spans the azimuths of the two ends differ
|
||||
* (곡선, 운곡교 8°), so the girder test fails; the chord from b to c then runs along the mean of the two azimuths (exact on a
|
||||
* circular arc). The partner is the end bearing whose chord is within 0.1° of that mean (WS04 when two are), and S is the
|
||||
* whole-bridge average Δz / chord. The check value is chord − mean azimuth, for reference only (no WS01).
|
||||
*/
|
||||
export function chordPairs(starts: PairStructure[], ends: PairStructure[]): BearingPair[] {
|
||||
const pairs: BearingPair[] = [];
|
||||
for (const here of starts) for (const b of here.bearings) {
|
||||
const candidates = ends.flatMap((s) => s.bearings.map((c) => {
|
||||
const d = { e: c.x - b.x, n: c.y - b.y };
|
||||
const directionDeg = (Math.atan2(d.e, d.n) / DEG + 360) % 360;
|
||||
const mean = (b.azimuthDeg + c.azimuthDeg + (Math.abs(b.azimuthDeg - c.azimuthDeg) > 180 ? 360 : 0)) / 2 % 360;
|
||||
return { c, structure: s.name, d, directionDeg, dev: angleDiffDeg(directionDeg, mean) };
|
||||
})).filter((t) => t.dev <= PAIR_AZIMUTH_TOL_DEG).sort((p, q) => p.dev - q.dev);
|
||||
if (!candidates.length) continue;
|
||||
const best = candidates[0];
|
||||
const distanceM = Math.hypot(best.d.e, best.d.n);
|
||||
pairs.push({
|
||||
from: { structure: here.name, no: b.no }, to: { structure: best.structure, no: best.c.no },
|
||||
distanceM, offsetM: 0, azimuthDiffDeg: angleDiffDeg(best.c.azimuthDeg, b.azimuthDeg), directionDeg: best.directionDeg,
|
||||
fromCheckSec: best.dev * 3600, toCheckSec: best.dev * 3600, slopePct: ((best.c.z - b.z) / distanceM) * 100,
|
||||
uphill: best.c.z > b.z ? best.structure : here.name, warnings: candidates.length > 1 ? ['WS04'] : [], kind: 'chord',
|
||||
});
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
/** The pair a bearing belongs to, on either end; undefined when it has none (then WS03 with a fallback S, not done here). */
|
||||
export function pairOf(pairs: BearingPair[], structure: string, no: number): BearingPair | undefined {
|
||||
return pairs.find((p) => (p.from.structure === structure && p.from.no === no) || (p.to.structure === structure && p.to.no === no));
|
||||
|
||||
@@ -63,6 +63,7 @@ export interface SolplateRow {
|
||||
pair?: string;
|
||||
/** 방위각 검사 (″): input azimuth against the pair direction; absent without a pair. */
|
||||
azimuthSec?: number;
|
||||
pairKind?: 'girder' | 'chord';
|
||||
/** Where S comes from: 계산 (짝) / 입력 (종단경사) / 가정 (다른 열) / 수정됨 / 없음. */
|
||||
source: SlopeSource;
|
||||
/** T typed by the user (수정됨) rather than automatic. */
|
||||
@@ -131,7 +132,7 @@ export function solplateRows(m: PierModel): SolplateRow[] {
|
||||
const checkLhs = p.T1 - p.T2;
|
||||
const checkRhs = (p.L * Math.abs(p.slopePct)) / 100;
|
||||
return {
|
||||
pair: s.pair ? `${s.pair.structure}-${CIRCLED_NO[s.pair.no - 1] ?? s.pair.no}` : undefined,
|
||||
pair: s.pair ? `${s.pair.structure}-${CIRCLED_NO[s.pair.no - 1] ?? s.pair.no}${s.pairKind === 'chord' ? ' (현)' : ''}` : undefined, pairKind: s.pairKind,
|
||||
azimuthSec: s.azimuthCheckSec, source: s.slopeSource, tTyped: s.tTyped, A: s.aMm, B: s.bMm, warnings: s.warnings,
|
||||
no: b.no,
|
||||
S: p.slopePct,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
import type { AbutRecord } from './abut/parse';
|
||||
import { structureOrder } from './abut/order';
|
||||
import { type BearingPair, girderPairs, type PairStructure } from './bearing-pair';
|
||||
import { type BearingPair, chordPairs, girderPairs, type PairStructure } from './bearing-pair';
|
||||
import type { PierInput } from './types';
|
||||
|
||||
export function bridgePairs(piers: Pick<PierInput, 'name' | 'bearings'>[], abuts: AbutRecord[]): BearingPair[] {
|
||||
@@ -18,5 +18,14 @@ export function bridgePairs(piers: Pick<PierInput, 'name' | 'bearings'>[], abuts
|
||||
const a = byAbut.get(ref.name)!;
|
||||
return { name: a.name, bearings: a.bearings.filter((b) => b.azimuthDeg !== undefined).map((b) => ({ no: b.no, x: b.pos.e, y: b.pos.n, z: b.z, azimuthDeg: b.azimuthDeg! })) };
|
||||
});
|
||||
return girderPairs(structures);
|
||||
const pairs = girderPairs(structures);
|
||||
// 교대만 (no pier): A1 ↔ A2 still pair along the chord (검토 2026-11-06); a single straight span already paired above.
|
||||
if (piers.length === 0) {
|
||||
const paired = new Set(pairs.flatMap((p) => [`${p.from.structure}#${p.from.no}`, `${p.to.structure}#${p.to.no}`]));
|
||||
const kind = new Map(abuts.map((a) => [a.name, a.type]));
|
||||
const free = (type: string) => structures.filter((s) => kind.get(s.name) === type)
|
||||
.map((s) => ({ ...s, bearings: s.bearings.filter((b) => !paired.has(`${s.name}#${b.no}`)) }));
|
||||
pairs.push(...chordPairs(free('A1'), free('A2')));
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
@@ -38,6 +38,8 @@ export interface BearingSole {
|
||||
pair?: { structure: string; no: number };
|
||||
/** 방위각 검사 (″): this bearing's input azimuth against the direction to its pair (Q-SH4, WS01 > 10″). Absent without a pair. */
|
||||
azimuthCheckSec?: number;
|
||||
/** girder (같은 거더) or chord (교대만, A1 ↔ A2 현). */
|
||||
pairKind?: 'girder' | 'chord';
|
||||
slopePct: number;
|
||||
slopeSource: SlopeSource;
|
||||
aMm: number;
|
||||
@@ -118,6 +120,7 @@ export function structureSoles(structure: string, bearings: SoleBearing[], setti
|
||||
const sign = Math.sign(slopePct);
|
||||
return {
|
||||
no: b.no, row: b.row, pair: pair ? (pair.from.structure === structure ? pair.to : pair.from) : undefined,
|
||||
pairKind: pair?.kind,
|
||||
azimuthCheckSec: pair ? (pair.from.structure === structure && pair.from.no === b.no ? pair.fromCheckSec : pair.toCheckSec) : undefined,
|
||||
slopePct, slopeSource: source, aMm, bMm, L, W, T, tTyped: typed, T1, T2, tReq: req[i], h3: s.h3Mm,
|
||||
dropMm: T - s.tRefMm, uphill: sign ? { e: sign * dir.e, n: sign * dir.n } : null, warnings,
|
||||
|
||||
+5
-3
@@ -1019,7 +1019,7 @@ function fillAbutBearingCard(
|
||||
box.append(
|
||||
table(
|
||||
['No.', '짝 받침', '방위각 검사 (″)', 'S (%)', 'S 근거', 'L', 'W', 'T', 'T1 (오르막 쪽)', 'T2', '검산 T1 − T2 ≈ L·|S|/100', '검사'],
|
||||
sol.map((r) => [String(r.no), r.pair ?? '없음', azimuthCheckText(r.azimuthSec), r.S.toFixed(3), r.source, String(r.L), String(r.W), String(r.T), String(r.T1), String(r.T2), r.check, r.warnings.length ? r.warnings.join(' · ') : '—']),
|
||||
sol.map((r) => [String(r.no), r.pair ?? '없음', azimuthCheckText(r.azimuthSec, r.pairKind), r.S.toFixed(3), r.source, String(r.L), String(r.W), String(r.T), String(r.T1), String(r.T2), r.check, r.warnings.length ? r.warnings.join(' · ') : '—']),
|
||||
),
|
||||
);
|
||||
// 솔플레이트 입력 (결정 2026-11-06): 교각 카드와 같은 항목, 받침이 1열이라 T 하나.
|
||||
@@ -5155,7 +5155,7 @@ function renderSolplateTable(wrap: HTMLDivElement, pier: Pier): void {
|
||||
for (const r of solplateRows(pier.model)) {
|
||||
const key = String(r.no);
|
||||
const tr = el('tr');
|
||||
tr.append(el('td', String(r.no)), el('td', r.pair ?? '없음'), el('td', azimuthCheckText(r.azimuthSec)));
|
||||
tr.append(el('td', String(r.no)), el('td', r.pair ?? '없음'), el('td', azimuthCheckText(r.azimuthSec, r.pairKind)));
|
||||
tr.appendChild(cellInput(Math.round(r.S * 1000) / 1000, r.source === '수정됨', `받침 ${r.no} S (%)`, (v) => {
|
||||
const next = { ...(settings.slopePct ?? {}) };
|
||||
if (v === undefined) delete next[key]; else next[key] = v;
|
||||
@@ -5181,8 +5181,10 @@ function renderSolplateTable(wrap: HTMLDivElement, pier: Pier): void {
|
||||
}
|
||||
|
||||
/** 방위각 검사 칸 (Q-SH4): the difference to the pair direction in arc seconds, ≤ 10″ 통과 / > 10″ WS01; '—' without a pair. */
|
||||
function azimuthCheckText(sec: number | undefined): string {
|
||||
function azimuthCheckText(sec: number | undefined, kind?: 'girder' | 'chord'): string {
|
||||
if (sec === undefined) return '— (짝 없음)';
|
||||
// 교대만 A1 ↔ A2 현: chord against the mean azimuth, for reference (the 10″ rule is for one girder).
|
||||
if (kind === 'chord') return `현 ${sec.toFixed(0)}″ (참고)`;
|
||||
return `${sec.toFixed(1)}″ ${sec > AZIMUTH_CHECK_SEC ? 'WS01' : '≤ 10″'}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -134,3 +134,37 @@ describe('두 노선 (광주 · 완도) — 목록 순서와 무관하게 거더
|
||||
expect(pairs.every((p) => p.from.structure.slice(3) === p.to.structure.slice(3) && p.from.structure.startsWith('A1') && p.from.no === p.to.no)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('교대만 (교각 0기): A1 ↔ A2 현 짝 (검토 2026-11-06)', () => {
|
||||
// Circular arc: A2_i lies at chord azimuth θ_i − δ, 350 m away, with azimuth θ_i − 2δ (the chord is the mean azimuth).
|
||||
const curved = (rec: typeof a1, name: string, type: 'A1' | 'A2', de = 0) => ({ ...rec, name, type, bearings: rec.bearings.map((b) => {
|
||||
const delta = 4, t = ((b.azimuthDeg! - delta) * Math.PI) / 180;
|
||||
return type === 'A1' ? { ...b, pos: { e: b.pos.e + de, n: b.pos.n } }
|
||||
: { ...b, azimuthDeg: b.azimuthDeg! - 2 * delta, pos: { e: b.pos.e + de + 350 * Math.sin(t), n: b.pos.n + 350 * Math.cos(t) }, z: b.z - 6.65 };
|
||||
}) });
|
||||
const abuts = [curved(a1, 'A1-광주', 'A1', 13), curved(a1, 'A1-완도', 'A1'), curved(a1, 'A2-광주', 'A2', 13), curved(a1, 'A2-완도', 'A2')];
|
||||
|
||||
it('two curved lines without piers: each A1 bearing pairs with the same number on its own line\'s A2 (현), S = Δz / chord', async () => {
|
||||
const { bridgePairs } = await import('../src/core/bridge-pairs');
|
||||
const pairs = bridgePairs([], abuts);
|
||||
expect(pairs).toHaveLength(10);
|
||||
expect(pairs.every((p) => p.kind === 'chord' && p.from.structure.slice(3) === p.to.structure.slice(3) && p.from.no === p.to.no && p.warnings.length === 0)).toBe(true);
|
||||
expect(pairs.every((p) => p.fromCheckSec < 1 && Math.abs(p.slopePct + 1.9) < 1e-6)).toBe(true);
|
||||
});
|
||||
|
||||
it('the 교대 card shows the 현 pair: A1-광주 ① ↔ A2-광주-① (현), S 계산', async () => {
|
||||
const { bridgePairs } = await import('../src/core/bridge-pairs');
|
||||
const { abutSolplateRows, resolveBearingRule } = await import('../src/core/abut/bearing-table');
|
||||
const { buildAbut } = await import('../src/core/abut/geometry');
|
||||
const { toAbutInput } = await import('../src/core/abut/parse');
|
||||
const rec = abuts[0];
|
||||
const rows = abutSolplateRows(buildAbut(toAbutInput(rec)), resolveBearingRule(), rec.bearingHeightMm, { pairs: bridgePairs([], abuts), slopeI: rec.slopeI });
|
||||
expect(rows[0]).toMatchObject({ pair: 'A2-광주-① (현)', pairKind: 'chord', source: '계산' });
|
||||
expect(rows[0].S).toBeCloseTo(-1.9, 6);
|
||||
});
|
||||
|
||||
it('with a pier loaded the chord rule is not used', async () => {
|
||||
const { bridgePairs } = await import('../src/core/bridge-pairs');
|
||||
expect(bridgePairs([p1], [a1, abuts[3]]).some((p) => p.kind === 'chord')).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user