Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions web/src/lib/codes/__tests__/edition-availability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,21 +168,26 @@ describe('every implemented rule cites the edition it came from', () => {
it('the registry never marks an edition available whose text is not supplied', () => {
// The invariant that makes the whole model trustworthy: availability is downstream of
// whether the app actually has the text to implement.
//
// This holds for EVERY role, not just the design-governing one. An earlier revision
// asserted `o.role === 'concrete'` here, which for a basis/loads/wind option reduces to
// `expect(false).toBe(false)` — the check passed vacuously for exactly the roles it was
// not covering, and three offered options whose text is not supplied went unnoticed.
const offending: string[] = [];
for (const role of REGULATION_ROLES) {
for (const o of optionsForRole(role)) {
if (!o.regulation) continue;
const info = findRegulation(o.regulation, o.edition as never);
if (!info) continue;
if (!info.textAvailable) {
// An available option whose text is absent is exactly the state this test forbids
// for the design-governing concrete role.
expect(
o.role === 'concrete',
`${o.adapterId} is AVAILABLE for role ${o.role} but its text is not supplied`,
).toBe(false);
offending.push(`${o.adapterId} (role ${o.role}, edition ${o.edition})`);
}
}
}
expect(
offending,
`offered for selection but their official text is not supplied: ${offending.join(', ')}`,
).toEqual([]);
});
});

Expand Down
24 changes: 12 additions & 12 deletions web/src/lib/codes/__tests__/roles-revisions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ describe('stack compatibility', () => {
describe('pending changes', () => {
it('detects a pending load-affecting change', () => {
const r = defaultRegulations();
r.wind = bindRole('wind', 'cirsoc102-2005');
r.wind = bindRole('wind', 'cirsoc102-2025');
expect(pendingRoles(r)).toEqual(['wind']);
expect(pendingRequiresLoadRegeneration(r)).toBe(true);
});
Expand Down Expand Up @@ -266,21 +266,21 @@ describe('migration from the CIRSOC-specific v1 shape', () => {
jurisdiction: { name: 'CABA', basis: 'adopted' },
concrete: { maxAggregateSizeMm: 19, shotcrete: false },
});
// A v1 project naming concreteEdition '2005' is bound to the edition IN FORCE and told,
// because CIRSOC 201-2005 is no longer available for design. No migration workflow is
// offered: results stored under 2005 came from rules the app no longer applies, so
// re-running the design is the only honest outcome. The load and wind roles are
// untouched — 101-2005 and 102-2005 remain available.
// A v1 project naming a 2005 edition is bound to the edition IN FORCE and told, because
// none of the 2005 texts are supplied and their rules are therefore not implemented. No
// migration workflow is offered: results stored under 2005 came from rules the app no
// longer applies, so re-running is the only honest outcome. This holds for the load and
// wind roles exactly as it does for concrete — 101-2005 and 102-2005 are reserved, and
// binding one would mean a project labelled 2005 running the 2025 rules.
expect(m.stored.roles.concrete.adapterId).toBe('cirsoc');
expect(m.stored.roles.concrete.edition).toBe('2025');
expect(m.notices.map((n) => n.key))
.toContain('regulations.migration.editionWithdrawn');
const withdrawn = m.notices.find(
const withdrawn = m.notices.filter(
(n) => n.key === 'regulations.migration.editionWithdrawn');
expect(withdrawn?.params?.role).toBe('concrete');
expect(withdrawn?.params?.edition).toBe('2005');
expect(withdrawn.map((n) => n.params?.role)).toEqual(['concrete', 'wind']);
for (const n of withdrawn) expect(n.params?.edition).toBe('2005');
expect(m.stored.roles.basis.adapterId).toBe('cirsoc101-2025-basis');
expect(m.stored.roles.wind.adapterId).toBe('cirsoc102-2005');
expect(m.stored.roles.wind.adapterId).toBe('cirsoc102-2025');
expect(m.stored.roles.wind.edition).toBe('2025');
expect(m.stored.roles.concrete.jurisdiction).toBe('CABA');
expect(m.stored.roles.concrete.adoption).toBe('adopted');
});
Expand Down
21 changes: 21 additions & 0 deletions web/src/lib/codes/cirsoc102/wind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,27 @@ export function velocityPressure(z: number, p: WindProject): number {
return 0.613 * kz * p.kzt * kd * ke * p.basicSpeed * p.basicSpeed;
}

/**
* Net along-wind pressure on a strip at height `z`, N/m² — windward push plus leeward pull.
*
* The windward term uses `q_z` AT THAT HEIGHT (§1.13: q = q_z on the windward wall, and
* K_z grows with z), which is the whole reason a caller distributing wind over levels must
* ask per level rather than reuse one sample. The leeward term uses `q_h` over the full
* height (§2.4.1) and therefore does not vary.
*
* The internal pressure GC_pi cancels: it acts on both faces with the same sign, so it
* drops out of the difference and is deliberately absent here.
*
* Lives beside the pressure rules rather than in the load generator so the two cannot
* drift — the generator asks for a pressure, it does not re-derive one.
*/
export function netAlongWindPressureAt(z: number, p: WindProject): number {
const lOverB = p.B > 0 ? p.L / p.B : 1;
const qz = velocityPressure(z, p);
const qh = velocityPressure(p.meanRoofHeight, p);
return qz * G_RIGID * CP_WINDWARD_WALL - qh * G_RIGID * cpLeewardWall(lOverB);
}

/**
* Full MWFRS wind pressures for one wind direction.
*
Expand Down
47 changes: 38 additions & 9 deletions web/src/lib/codes/roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,10 +128,15 @@ export const ROLE_CATALOG: readonly RoleOption[] = Object.freeze([
maturity: 'VALIDATED', requiresConfig: false,
},
{
// RESERVED, not selectable — same reason as CIRSOC 201-2005 below. The official
// 101-2005 text is not supplied (`REGULATIONS` records `textAvailable: false`), and
// `cirsoc101/combinations.ts` implements the 2025 §2.3.2 combinations only, citing
// 2025 clauses. Offering this option meant a project stamped 2005 whose combinations
// came from the 2025 text — a result citing a rule it did not apply.
adapterId: 'cirsoc101-2005-basis', role: 'basis', regulation: 'cirsoc-101',
edition: '2005', nameKey: 'regulations.name.cirsoc101', family: 'cirsoc',
maturity: 'IMPLEMENTED_PROVISIONAL', requiresConfig: false,
noteKey: 'regulations.note.legacyEdition',
maturity: 'UNSUPPORTED', availability: 'UNAVAILABLE_SOURCE', requiresConfig: false,
noteKey: 'regulations.note.editionTextNotSupplied',
},
{
adapterId: 'en1990', role: 'basis', edition: 'EN 1990:2002',
Expand All @@ -146,10 +151,13 @@ export const ROLE_CATALOG: readonly RoleOption[] = Object.freeze([
maturity: 'VALIDATED', requiresConfig: true,
},
{
// RESERVED, not selectable. `cirsoc101/live-loads.ts` implements the 2025 Table 4.1
// occupancies and the §4.7.2 reduction only; there is no 2005 branch and no 2005 text
// to write one from.
adapterId: 'cirsoc101-2005-loads', role: 'loads', regulation: 'cirsoc-101',
edition: '2005', nameKey: 'regulations.name.cirsoc101', family: 'cirsoc',
maturity: 'IMPLEMENTED_PROVISIONAL', requiresConfig: true,
noteKey: 'regulations.note.legacyEdition',
maturity: 'UNSUPPORTED', availability: 'UNAVAILABLE_SOURCE', requiresConfig: true,
noteKey: 'regulations.note.editionTextNotSupplied',
},
{
adapterId: 'en1991-1-1', role: 'loads', edition: 'EN 1991-1-1',
Expand All @@ -164,10 +172,14 @@ export const ROLE_CATALOG: readonly RoleOption[] = Object.freeze([
maturity: 'VALIDATED', requiresConfig: true,
},
{
// RESERVED, not selectable. `cirsoc102/wind.ts` is a full rebuild against the 2025
// text — K_e, the pressure coefficients, the exposure and enclosure rules — and takes
// no edition parameter. Under this label the wind loads and every clause ref were the
// 2025 edition's, which is the mislabelling the availability model exists to stop.
adapterId: 'cirsoc102-2005', role: 'wind', regulation: 'cirsoc-102',
edition: '2005', nameKey: 'regulations.name.cirsoc102', family: 'cirsoc',
maturity: 'IMPLEMENTED_PROVISIONAL', requiresConfig: true,
noteKey: 'regulations.note.legacyEdition',
maturity: 'UNSUPPORTED', availability: 'UNAVAILABLE_SOURCE', requiresConfig: true,
noteKey: 'regulations.note.editionTextNotSupplied',
},
{
adapterId: 'en1991-1-4', role: 'wind', edition: 'EN 1991-1-4',
Expand Down Expand Up @@ -664,26 +676,43 @@ export function migrateRegulations(raw: unknown): RegulationsMigration {
// than bound to something inapplicable or silently left unset. No migration workflow is
// offered: stored 2005 results were produced by rules the app no longer applies, so
// re-running the design is the only honest outcome.
// The same applies to the 2005 load and wind editions: their texts are not supplied
// either, so they are bound to the edition in force and the user is TOLD, rather than
// bound to a label whose rules would come from the 2025 text.
if (concreteEd === '2005') {
notices.push({
key: 'regulations.migration.editionWithdrawn',
params: { role: 'concrete', edition: '2005' },
});
}
if (loadEd === '2005') {
for (const role of ['basis', 'loads'] as const) {
notices.push({
key: 'regulations.migration.editionWithdrawn',
params: { role, edition: '2005' },
});
}
}
if (windEd === '2005') {
notices.push({
key: 'regulations.migration.editionWithdrawn',
params: { role: 'wind', edition: '2005' },
});
}
roles.concrete = {
...bindRole('concrete', 'cirsoc', common),
state: 'applied', appliedAtRevision: 0,
};
roles.basis = {
...bindRole('basis', loadEd === '2005' ? 'cirsoc101-2005-basis' : 'cirsoc101-2025-basis', common),
...bindRole('basis', 'cirsoc101-2025-basis', common),
state: 'applied', appliedAtRevision: 0,
};
roles.loads = {
...bindRole('loads', loadEd === '2005' ? 'cirsoc101-2005-loads' : 'cirsoc101-2025-loads', common),
...bindRole('loads', 'cirsoc101-2025-loads', common),
state: 'applied', appliedAtRevision: 0,
};
roles.wind = {
...bindRole('wind', windEd === '2005' ? 'cirsoc102-2005' : 'cirsoc102-2025', common),
...bindRole('wind', 'cirsoc102-2025', common),
state: 'applied', appliedAtRevision: 0,
};

Expand Down
32 changes: 32 additions & 0 deletions web/src/lib/engine/loads/__tests__/load-plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,38 @@ describe('wind uses the CIRSOC 102-2025 engine', () => {
const p = windOn({ directions: { x: true, y: true } });
expect(p.cases.filter((c) => c.type === 'W')).toHaveLength(2);
});

it('distributes windward pressure by height instead of applying the base value everywhere', () => {
// q_z rises with height (§1.13, K_z), and `wind.ts` evaluates the windward wall at both
// the base and the mean roof height specifically so this caller can distribute. Taking
// the first sample — z = min(5, h), the weakest point — and reusing it for every level
// under-predicts wind on everything above 5 m, which is unconservative.
const model = frame(4, 6, 5); // levels at 5, 10, 15, 20 m
const p = buildLoadPlan(input({
model,
wind: {
enabled: true, basicSpeed: 45, exposure: 'C', enclosure: 'enclosed',
siteAltitudeM: 0, kzt: 1, kztSurveyed: true, roofSlopeDeg: 20, rigid: true,
directions: { x: true, y: false },
},
}));

// The §1.10 minimum must not be what decides this, or the comparison proves nothing.
expect(p.unsupportedKeys.map((u) => u.key)).not.toContain('loadPlan.note.windMinimumGoverns');

const byLevel = new Map<number, number>();
for (const n of p.nodal) {
if (n.caseType !== 'W') continue;
const z = model.nodes.get(n.nodeId)!.z!;
byLevel.set(z, (byLevel.get(z) ?? 0) + Math.abs(n.fx));
}
// Levels 5, 10 and 15 all carry a 5 m tributary height (only the roof level is halved),
// so their totals are directly comparable without normalising.
const totals = [5, 10, 15].map((z) => byLevel.get(z) ?? 0);
expect(totals.every((t) => t > 0)).toBe(true);
expect(totals[1]).toBeGreaterThan(totals[0]);
expect(totals[2]).toBeGreaterThan(totals[1]);
});
});

// ─── Seismic ─────────────────────────────────────────────────────
Expand Down
18 changes: 12 additions & 6 deletions web/src/lib/engine/loads/load-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import {
type ElementKind, type OccupancyEntry,
} from '../../codes/cirsoc101/live-loads';
import {
applyMinimumWindLoad, computeWindPressures,
applyMinimumWindLoad, computeWindPressures, netAlongWindPressureAt,
type Enclosure, type Exposure, type WindProject,
} from '../../codes/cirsoc102/wind';
import {
Expand Down Expand Up @@ -422,16 +422,18 @@ export function buildLoadPlan(input: LoadPlanInput): LoadPlan {
windQh = fromProject(res.qhNm2, 'N/m²');

// Windward + leeward on each level, distributed over that level's nodes.
const ww = res.pressures.find((p) => p.surface === 'windwardWall' && p.gcpiSign === 1);
const lw = res.pressures.find((p) => p.surface === 'leewardWall' && p.gcpiSign === 1);
const net = (Math.abs(ww?.pNm2 ?? 0) + Math.abs(lw?.pNm2 ?? 0)) / 1000; // kPa

//
// Sampled PER LEVEL: the windward velocity pressure grows with height, so taking one
// sample and reusing it for every storey under-predicts wind everywhere above it. The
// net pressure is asked of the code layer rather than assembled from the surface list
// here, so the height rule has exactly one implementation.
const elevated = levels.filter((l) => l.elevation > 0);
for (let i = 0; i < elevated.length; i++) {
const lv = elevated[i];
const below = i === 0 ? 0 : elevated[i - 1].elevation;
const above = i === elevated.length - 1 ? lv.elevation : elevated[i + 1].elevation;
const tribH = (lv.elevation - below) / 2 + (above - lv.elevation) / 2;
const net = netAlongWindPressureAt(lv.elevation, project) / 1000; // kPa
const force = net * across * tribH;
const min = applyMinimumWindLoad(force * 1000, across * tribH, 0);
const applied = min.totalN / 1000;
Expand All @@ -454,9 +456,13 @@ export function buildLoadPlan(input: LoadPlanInput): LoadPlan {
type: 'W', nameKey: 'autoLoad.windCaseDir',
nameParams: { dir: dir.toUpperCase(), v: input.wind.basicSpeed },
});
// The reported net is the one at the mean roof height — the largest of the profile,
// and the one that pairs with the q_h printed beside it. Levels below it received
// less, which is the point of sampling per level.
derivation.push(msg('loadPlan.derivation.wind', {
dir: dir.toUpperCase(), qh: round(res.qhNm2, 0),
net: round(net, 3), front: round(across, 1),
net: round(netAlongWindPressureAt(project.meanRoofHeight, project) / 1000, 3),
front: round(across, 1),
}));
}
}
Expand Down
103 changes: 103 additions & 0 deletions web/src/lib/store/__tests__/regulations-persistence.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { modelStore } from '../model.svelte';
import { REGULATIONS_SCHEMA_VERSION, bindRole, defaultRegulations } from '../../codes/roles';
import { bump, emptyRevisions } from '../../codes/revisions';

/**
* The regulation stack and the revision vector live on the model for the same reason
* `codeSettings` does: so they travel through every persistence path for free — .ded
* save/open, tab capture/restore, URL share and autosave all go through
* snapshot()/restore(), and so does undo/redo.
*
* These pin that they actually do. Without them the stack was declared on the model and
* on `ModelSnapshot`, but `snapshot()` never emitted it and `restore()` never read it, so
* every save/open and every undo silently reset the project to `defaultRegulations()` and
* dropped the staleness stamps that gate stored results.
*
* Each round-trip test blanks the live fields between snapshot and restore. Reading them
* back without that step proves nothing: the value would still be sitting on the model
* from before the snapshot, and the assertion passes whether or not `restore()` works.
*/
describe('project regulations persistence', () => {
beforeEach(() => {
modelStore.clear();
});

function boundToConcrete2025() {
const roles = defaultRegulations();
roles.concrete = {
...bindRole('concrete', 'cirsoc', { jurisdiction: 'CABA', adoption: 'adopted' }),
state: 'applied', appliedAtRevision: 0,
};
return { version: REGULATIONS_SCHEMA_VERSION, roles };
}

/** What a fresh tab or a newly opened file looks like before restore() runs. */
function blankLiveStack() {
modelStore.model.regulations = undefined;
modelStore.model.revisions = undefined;
}

it('round-trips the role bindings through snapshot and restore', () => {
modelStore.model.regulations = boundToConcrete2025();
const snap = modelStore.snapshot();
blankLiveStack();

modelStore.restore(snap);

expect(modelStore.model.regulations?.roles.concrete.adapterId).toBe('cirsoc');
expect(modelStore.model.regulations?.roles.concrete.jurisdiction).toBe('CABA');
expect(modelStore.model.regulations?.roles.concrete.state).toBe('applied');
});

it('round-trips the revision vector, which is what gates stale results', () => {
modelStore.model.revisions = bump(emptyRevisions(), 'analysis');
const before = modelStore.model.revisions.analysis;
expect(before).toBeGreaterThan(0);
const snap = modelStore.snapshot();
blankLiveStack();

modelStore.restore(snap);

expect(modelStore.model.revisions?.analysis).toBe(before);
});

it('survives a JSON round-trip, which is what .ded and URL sharing actually do', () => {
modelStore.model.regulations = boundToConcrete2025();
modelStore.model.revisions = bump(emptyRevisions(), 'analysis');

const wire = JSON.parse(JSON.stringify(modelStore.snapshot()));
blankLiveStack();
modelStore.restore(wire);

expect(modelStore.model.regulations?.roles.concrete.adapterId).toBe('cirsoc');
expect(modelStore.model.revisions?.analysis).toBe(1);
});

it('unsets a stored edition that has since been withdrawn rather than carrying it', () => {
// A project saved naming an edition whose text is not supplied must not come back
// bound to it — the rules cannot be applied, so the binding would be a lie.
const roles = defaultRegulations();
roles.concrete = {
...roles.concrete, adapterId: 'cirsoc-2005', edition: '2005', state: 'applied',
};
const snap = modelStore.snapshot();
blankLiveStack();

modelStore.restore({
...snap,
regulations: { version: REGULATIONS_SCHEMA_VERSION, roles },
});

expect(modelStore.model.regulations?.roles.concrete.adapterId).toBeNull();
});

it('gives a new project the default stack instead of inheriting the previous one', () => {
modelStore.model.regulations = boundToConcrete2025();
modelStore.model.regulations.roles.concrete.jurisdiction = 'Santa Fe';

modelStore.clear();

expect(modelStore.model.regulations?.roles.concrete.jurisdiction ?? '').toBe('');
});
});
Loading