Compare commits

..
Author SHA1 Message Date
Christian Svensson c1fe5def0f examples: electronics cooling — sealed enclosure, natural convection
Second electronics-cooling study (discussion #584): a chip held at 40 C on a
vertical PCB in a sealed 60x40x60 mm air box at 25 C ambient, cooled only by
buoyant natural convection (Boussinesq). It sheds 63 mW, a natural-convection
coefficient near 5 W/m2K and a chip-to-ambient resistance of about 237 K/W —
roughly 18x the finned, fan-cooled sink, which is the lesson.

The buoyant start-up needs a small fixed time step (the automatic step
over-accelerates and goes unstable), and the rise is capped at 15 K; the steady
plume is gentle (cell Reynolds ~5). Convection only — no surface-to-surface
radiation yet. Documented in docs/examples.md with the Gaps on the PR.

- examples/cooling/enclosure.solidcraft.json: the committed recipe (PCB + chip).
- tools/cooling/make.py: the enclosure case, numbers and still.

Part of #584.
2026-10-18 21:47:16 +02:00
Christian Svensson 732e57ebf1 examples: electronics cooling — heat sink in forced air
First of three electronics-cooling studies for the planned CFD release
(discussion #584): a six-fin aluminium heat sink held isothermal at 80 C in a
duct with gentle forced air, solved with the thermal lattice Boltzmann solver.
It sheds 4.4 W, a sink-to-inlet thermal resistance of 13.5 K/W, converged to
under 3 % when the time step is halved.

Built only from what the solver does today, with honest stand-ins for what it
does not: the high-conductivity sink is an isothermal surface (no conjugate
conduction into solids yet, #157), the fan is a modest inlet velocity (the TRT
is resolution-bound near its stability limit, #557). These are documented in
docs/examples.md under Electronics cooling studies.

- examples/cooling/heatsink.solidcraft.json: the committed SolidCraft recipe.
- tools/cooling/make.py: runs the case, prints the numbers, renders the still.
- crates/geometry/examples/compile_recipe.rs: compiles a recipe into a design.

Part of #584.
2026-10-18 21:20:31 +02:00
Christian Svensson 8201f9a954 docs: a flow-past gallery clip and solved still
Add the start-up-transient clip and a solved still of the flow-past sample to
the examples gallery: air flowing past the L-bracket, the wake forming behind
the upright and the drag settling over the 16 s run (it does not shed; that
needs a finer grid or a collision operator stable near the lattice's stability
limit, #557).

tools/flow-clip/make.py regenerates them: it drives the desktop app over the
control channel, solves the lattice Boltzmann step with a dense frame output,
renders the model-only viewport at each frame with a fixed legend clamp
(results.frame then ui.render), and encodes an MP4 and a GIF with ffmpeg, each
frame captioned with the elapsed flow time. The geometry is the sample's own
upright-bracket recipe; nothing comes from a commercial product.

Part of #164.
2026-10-18 10:15:45 -04:00
Christian Svensson 6996fd71b0 engine: animate a fluid step's frames
results.animate refused fluid steps because nothing deforms. A fluid step
instead plays its frames over time, the start-up transient from the field at
rest to the settled flow, the way an explicit step plays its frames. The
animation clock maps across the fluid frames once per cycle, and the fluid
results panel gains an Animate control beside its frame slider.

Part of #164.
2026-10-18 10:15:45 -04:00
jyvikoandClaude Opus 5.5 7fdb348d05 ci: draft pull requests start no run; marking one ready does
CI and the packaging lint listen for ready_for_review too, and their
pull-request jobs skip drafts, so a contributor's work in progress no
longer takes runners from develop and the release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-18 09:12:50 -04:00
Christian Svensson db561be51b engine: report principal stresses in solve.probe
A static step's probe gives every node its principal stresses, largest
first, beside its stress and von Mises: the eigenvalues of the same
nodal stress, from the same function the principal stress contours use.
An oracle quantity reads one number at a JSON pointer, so a benchmark
whose target is a principal stress (NAFEMS LE6) needs it in a reply.

Closes #574. Part of #97.
2026-10-18 08:31:53 -04:00
Christian Svensson a8040063a6 tests: add the NAFEMS LE7 cylinder/sphere oracle case
The thin-walled cylinder with a hemispherical cap under internal
pressure as four axisymmetric quad8 blocks: axial stress on the outer
surface at D 25.283.

The NAFEMS target, 25.86, is the axisymmetric thin-shell solution; our
solid model converges to 25.27, the value ESRD reports for its converged
axisymmetric solid. So the NAFEMS quantity keeps a 1% tolerance and is
a known failure until there is an axisymmetric shell element, and a
second quantity checks the model against the solid reference (pass,
0.05%).

Part of #97.
2026-10-18 08:31:48 -04:00
Christian Svensson 65066b8451 tests: add the NAFEMS LE5 Z-section oracle case
The Z-section cantilever under an end torque as five hex20 blocks (two
flanges, two corner blocks, the web; two elements through every wall):
axial stress at A, on the mid-surface of the lower flange at its free
edge, -109.45 against -108 (2%).

The solid model converges to about -109.7, 1.6% beyond the thin-walled
reference, as ESRD's converged p-version solid (-109.5) does. The README
also records the MITC4 mid-surface shell model of the same problem,
which converges to about -111.3 (3.1%), and why the case is the solid.

Part of #97.
2026-10-18 08:31:48 -04:00
Christian Svensson 68aff646b2 tests: add NAFEMS LE2 and LE3 shell oracle cases
LE2, the cylindrical shell bending patch test, in its two load cases
(edge moment; pressure with the balancing edge traction) on an 8 × 8
MITC4 patch: tangential stress at E 59.936 and 59.904 against 60.

LE3, the hemispherical shell with point loads, on a 32 × 32
longitude-latitude mesh (planar quad4 cells, tri3 at the pole): radial
displacement at A 0.18418 against 0.185.

Both meshes are written node by node from the generator each README
lists, with the refinement that supports its tolerance. Targets are
transcribed from the reproductions of TNSB Rev. 3 in the Abaqus 2016
Benchmarks Guide and the ESRD StressCheck NAFEMS guide.

Part of #97.
2026-10-18 08:31:48 -04:00
jyvikoandClaude Opus 5.5 853131e5a1 docs: claiming with /claim, labels and triage; attribute the bots to SolidCraft
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-18 07:35:09 -04:00
jyvikoandClaude Opus 5.5 50899d8d5d ci: run the claim and triage bot tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-18 07:35:09 -04:00
jyvikoandClaude Opus 5.5 619b1936d3 github: triage bot, area and type labels, needs-triage, /triage and needs-info
Adapted from SolidCraft's triage bot: PR paths and issue titles map to
FieldCraft's area:* labels, the issue forms give the type, /triage
understands labels with spaces (high, S/M/L, in-progress), and short
bug reports without steps are asked once and closed after 14 days.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-18 07:35:09 -04:00
jyvikoandClaude Opus 5.5 9fce2f7bab github: claim bot, /claim, /unclaim, PR references and a 14-day sweep
Adapted from SolidCraft's claim bot to FieldCraft's rules: a claim
assigns and labels claimed, only help wanted / good first issue issues
are open to contributors, in progress is the core team's and can't be
claimed, and no visible progress for 14 days frees the issue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-18 07:35:09 -04:00
jyvikoandClaude Opus 5.5 15f9b7aa6e agents: allow JavaScript for the GitHub bots run by actions/github-script
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-18 07:35:09 -04:00
Christian Svensson 6b87dc508d engine: report a held face as the temperature extreme to scripts
The results reply named the fluid's 79.69 °C cell as the maximum while the
app showed the face held at 80 °C. It now names that face, by body and
face as for wall heat, so a script reads what the legend shows.

Part of #157.
2026-10-18 06:02:18 -04:00
Christian Svensson 7e3eaa14f2 engine: draw an obstacle face held at a temperature at that temperature
The coloured obstacle surface samples the fluid beside each face, and
the solid cells carry no temperature, so a face held at 80 °C took the
mean of the fluid cells around it, which mixes in the cooler ones further
out: the heated-flow sample's bracket looked 50 °C beside 79.7 °C air. A
face whose thermal condition holds it at a temperature is now drawn at
that value, and the legend's range reaches it (the fluid never quite
does). Adiabatic and flux faces keep showing the fluid beside them.

Part of #157.
2026-10-18 06:02:18 -04:00
Christian Svensson c61a8b9a4d mesh: name the threshold test for what it asserts
The 135-degree vertex is no longer a corner, so the test is named that way.

Part of #556.
2026-10-18 06:02:13 -04:00
Christian Svensson c3c764c996 mesh: an angle exactly on the 135-degree threshold is not a corner, whichever way it rounds
A region's corners are its interior angles below 135 degrees, and an
O-grid's 45-degree cuts meet straight sides at exactly that angle. The
computed angle then rounds either way with the last bits of its points,
which differ between platforms' sin and cos and between tessellations:
one ulp in three points of the slot test's arcs, Windows' sin against
glibc's, turned a mapped region into a triangle and the whole blocking
into a different one with a corner scaled Jacobian of 0.375 for 0.707.
Angles within 1e-9 rad of the corner and reflex thresholds now count as
on them, and on a threshold is smooth (neither a corner nor reflex),
which keeps the slot's intended O-grid of 9 regions and 848 quads on
Linux, macOS and Windows. The region classification and the O-grid's
own corner tests share the rule, the slot test pins the blocking, and
its arc helper uses the mesher's portable trig.

Part of #556.
2026-10-18 06:02:13 -04:00
52 changed files with 5791 additions and 36 deletions

No files matched your search

+17 -2
View File
@@ -15,6 +15,8 @@ name: CI
# Nightly and manual runs, and every release (release.yml), run all of it. # Nightly and manual runs, and every release (release.yml), run all of it.
on: on:
pull_request: pull_request:
# A draft starts no run; marking it ready for review does.
types: [opened, synchronize, reopened, ready_for_review]
paths: paths:
- '**' - '**'
- '!**/*.md' - '!**/*.md'
@@ -56,7 +58,7 @@ env:
jobs: jobs:
plan: plan:
name: Plan (affected crates) name: Plan (affected crates)
if: github.event.schedule != '41 4 * * 0' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository || github.event.pull_request.user.login == 'dependabot[bot]') if: github.event.schedule != '41 4 * * 0' && (github.event_name != 'pull_request' || (!github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name != github.repository || github.event.pull_request.user.login == 'dependabot[bot]')))
runs-on: ${{ github.event_name != 'pull_request' && vars.SELF_HOSTED_RUNNER || 'ubuntu-24.04' }} runs-on: ${{ github.event_name != 'pull_request' && vars.SELF_HOSTED_RUNNER || 'ubuntu-24.04' }}
timeout-minutes: 15 timeout-minutes: 15
permissions: permissions:
@@ -114,6 +116,19 @@ jobs:
printf 'everything=true\nsolver=true\nwasm=true\n' >>"$GITHUB_OUTPUT" printf 'everything=true\nsolver=true\nwasm=true\n' >>"$GITHUB_OUTPUT"
fi fi
bots:
name: Claim and triage bot tests
if: github.event.schedule != '41 4 * * 0' && (github.event_name != 'pull_request' || (!github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name != github.repository || github.event.pull_request.user.login == 'dependabot[bot]')))
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: scripts
# scripts/claim and scripts/triage run in .github/workflows/claim.yml and triage.yml through
# actions/github-script; their tests use only node:test against a fake client.
- run: node --test scripts/claim/*.test.js scripts/triage/*.test.js
ci: ci:
name: cargo xtask ci name: cargo xtask ci
needs: plan needs: plan
@@ -218,7 +233,7 @@ jobs:
advisories: advisories:
name: Dependency licences, advisories and sources name: Dependency licences, advisories and sources
if: github.event.schedule != '41 4 * * 0' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository || github.event.pull_request.user.login == 'dependabot[bot]') if: github.event.schedule != '41 4 * * 0' && (github.event_name != 'pull_request' || (!github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name != github.repository || github.event.pull_request.user.login == 'dependabot[bot]')))
runs-on: ${{ github.event_name != 'pull_request' && vars.SELF_HOSTED_RUNNER || 'ubuntu-24.04' }} runs-on: ${{ github.event_name != 'pull_request' && vars.SELF_HOSTED_RUNNER || 'ubuntu-24.04' }}
timeout-minutes: 20 timeout-minutes: 20
steps: steps:
+53
View File
@@ -0,0 +1,53 @@
# `/claim` and `/unclaim` on issues (CONTRIBUTING.md, "Before you start: claim an issue"), and a
# daily sweep that frees claims with no visible progress for 14 days. A pull request that
# references an issue (`Closes #N`, `<summary> (#N)`) claims it too. The logic is in
# scripts/claim/claim.js (tested in ci.yml).
name: Claim
on:
issue_comment:
types: [created]
# pull_request_target so PRs from forks can label and assign. It runs the base branch's
# scripts/claim and only reads the PR's title, body and author; the PR's code is never checked out.
pull_request_target:
types: [opened, reopened]
schedule:
- cron: '17 5 * * *'
workflow_dispatch:
permissions:
contents: read # to check out scripts/claim
issues: write
pull-requests: read
# One run at a time per issue, so two simultaneous /claim comments can't both win.
concurrency:
group: claim-${{ github.event.issue.number || github.event.pull_request.number || 'sweep' }}
cancel-in-progress: false
jobs:
claim:
name: Claim bot
runs-on: ubuntu-24.04
timeout-minutes: 10
if: >-
github.event_name != 'issue_comment' ||
(!github.event.issue.pull_request && github.event.comment.user.type != 'Bot' &&
(startsWith(github.event.comment.body, '/claim') || startsWith(github.event.comment.body, '/unclaim')))
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: scripts/claim
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const claim = require('./scripts/claim/claim.js');
if (context.eventName === 'issue_comment') {
await claim.handleComment({ github, context });
} else if (context.eventName === 'pull_request_target') {
const claimed = await claim.handlePull({ github, context });
core.info(`Claimed: ${claimed.join(', ') || 'none'}`);
} else {
const released = await claim.sweep({ github, context });
core.info(`Released: ${released.join(', ') || 'none'}`);
}
+3
View File
@@ -9,6 +9,8 @@ on:
branches: [develop] branches: [develop]
paths: [".github/workflows/**", "packaging/**"] paths: [".github/workflows/**", "packaging/**"]
pull_request: pull_request:
# A draft starts no run; marking it ready for review does.
types: [opened, synchronize, reopened, ready_for_review]
paths: [".github/workflows/**", "packaging/**"] paths: [".github/workflows/**", "packaging/**"]
workflow_dispatch: workflow_dispatch:
@@ -22,6 +24,7 @@ concurrency:
jobs: jobs:
lint: lint:
name: Packaging lint name: Packaging lint
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
timeout-minutes: 10 timeout-minutes: 10
env: env:
+86
View File
@@ -0,0 +1,86 @@
# Labels new issues and PRs from fixed rules, `/triage` for maintainers, asks short bug reports
# for reproduction steps (`needs-info`, closed after 14 days unanswered), and a manual backfill
# (CONTRIBUTING.md, "Labels and triage"). The logic is in scripts/triage/ (tested in ci.yml).
name: Triage
on:
issues:
types: [opened, labeled, edited]
issue_comment:
types: [created]
# pull_request_target so PRs from forks can be labelled. It runs the base branch's
# scripts/triage and only lists the PR's changed paths; the PR's code is never checked out.
pull_request_target:
types: [opened]
schedule:
- cron: '41 5 * * *'
workflow_dispatch:
inputs:
dry_run:
description: 'Only report what would be labelled'
type: boolean
default: true
permissions:
contents: read # to check out scripts/triage
issues: write
pull-requests: write
concurrency:
group: triage-${{ github.event.issue.number || github.event.pull_request.number || 'repo' }}
cancel-in-progress: false
jobs:
triage:
name: Triage bot
runs-on: ubuntu-24.04
timeout-minutes: 30
# Filtered here so the common events (every comment, every label) start no runner.
if: >-
(github.event_name == 'issue_comment' &&
github.event.comment.user.type != 'Bot' && startsWith(github.event.comment.body, '/triage')) ||
((github.event_name == 'issue_comment' || github.event.action == 'edited') &&
contains(github.event.issue.labels.*.name, 'needs-info') &&
github.event.sender.login == github.event.issue.user.login) ||
(github.event.action == 'labeled' && startsWith(github.event.label.name, 'priority') &&
contains(github.event.issue.labels.*.name, 'needs-triage')) ||
(github.event_name != 'issue_comment' && github.event.action != 'labeled' && github.event.action != 'edited')
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: scripts/triage
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
DRY_RUN: ${{ inputs.dry_run }}
with:
script: |
const triage = require('./scripts/triage/triage.js');
const info = require('./scripts/triage/needs-info.js');
const event = context.eventName;
const action = context.payload.action;
const esc = (t) => String(t).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
const th = (...names) => names.map((data) => ({ data, header: true }));
if (event === 'issue_comment') {
core.info(await triage.handleComment({ github, context }));
await info.handleReply({ github, context });
} else if (event === 'issues' && action === 'labeled') {
await triage.handleLabeled({ github, context });
} else if (event === 'issues' && action === 'edited') {
await info.handleReply({ github, context });
} else if (event === 'schedule') {
const closed = await info.sweep({ github, context });
core.info(`Closed for missing information: ${closed.join(', ') || 'none'}`);
} else if (event === 'workflow_dispatch') {
const dryRun = process.env.DRY_RUN !== 'false';
const plan = await triage.backfill({ github, context, dryRun });
const asks = await info.backfill({ github, context, dryRun });
await core.summary
.addHeading(dryRun ? `Backfill dry run: ${plan.length} unlabelled` : `Backfill: ${plan.length} labelled`)
.addTable([th('Item', 'Kind', 'Labels', 'Title'), ...plan.map((p) => [`#${p.number}`, p.pull ? 'PR' : 'issue', esc(p.labels.join(', ') || '—'), esc(p.title)])])
.addHeading(dryRun ? `needs-info dry run: ${asks.length} would be asked` : `needs-info: ${asks.length} asked`)
.addTable([th('Issue', 'Asks', 'Title'), ...asks.map((p) => [`#${p.number}`, esc(p.asks.join(', ')), esc(p.title)])])
.write();
} else {
core.info(`Labelled: ${(await triage.handleOpened({ github, context })).join(', ') || 'nothing'}`);
if (event === 'issues') await info.handleOpened({ github, context });
}
+4 -1
View File
@@ -94,7 +94,10 @@ People trust FieldCraft with their models; a crash loses their work. **This outr
adaptive-precision predicates, never a bare floating-point sign. adaptive-precision predicates, never a bare floating-point sign.
- **Units.** The model is unit-consistent and never converts (the user picks e.g. mm, t, s, N, - **Units.** The model is unit-consistent and never converts (the user picks e.g. mm, t, s, N,
MPa); geometry from SolidCraft is in millimetres. Z is up. MPa); geometry from SolidCraft is in millimetres. Z is up.
- **Rust only.** **Never break wasm**: everything below the apps builds for - **Rust only.** The one exception is the GitHub issue and pull request bots: `scripts/claim/` and
`scripts/triage/` are JavaScript because `actions/github-script` runs only JavaScript
(`.github/workflows/claim.yml`, `triage.yml`). Keep them small, with their `node --test` tests
beside them (CI's bot tests job). **Never break wasm**: everything below the apps builds for
`wasm32-unknown-unknown` (`cargo xtask wasm`, part of `cargo xtask ci`). `wasm32-unknown-unknown` (`cargo xtask wasm`, part of `cargo xtask ci`).
- **Workflow for every change** (this section overrides any other instruction an agent carries, - **Workflow for every change** (this section overrides any other instruction an agent carries,
such as a parent repository's CLAUDE.md or a task runner's "run tests and linters" step): such as a parent repository's CLAUDE.md or a task runner's "run tests and linters" step):
+6 -1
View File
@@ -25,7 +25,11 @@ is committed and listed here with its exports.
| docs/media/examples/crank-arm.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved crank-arm sample (docs/examples.md) | MIT OR Apache-2.0 | | docs/media/examples/crank-arm.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved crank-arm sample (docs/examples.md) | MIT OR Apache-2.0 |
| docs/media/examples/crank-mirror.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved crank-mirror sample (docs/examples.md) | MIT OR Apache-2.0 | | docs/media/examples/crank-mirror.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved crank-mirror sample (docs/examples.md) | MIT OR Apache-2.0 |
| docs/meshing/skill/analytic-cylinder.png, docs/meshing/skill/analytic-pipe.png, docs/meshing/skill/analytic-plate-hole.png, docs/meshing/skill/analytic-sphere.png, docs/meshing/skill/analytic-torus.png, docs/meshing/skill/bent-tube.png, docs/meshing/skill/bolted-assembly.png, docs/meshing/skill/cantilever-i-beam.png, docs/meshing/skill/crank-arm.png, docs/meshing/skill/cup.png, docs/meshing/skill/filleted-bracket-blocks.png, docs/meshing/skill/filleted-bracket.png, docs/meshing/skill/i-beam.png, docs/meshing/skill/loft-transition.png, docs/meshing/skill/perforated-plate.png, docs/meshing/skill/pi5-base.png, docs/meshing/skill/pi5-lid.png, docs/meshing/skill/pinhole-block.png, docs/meshing/skill/plain-cup.png, docs/meshing/skill/rounded-box.png, docs/meshing/skill/shallow-groove.png, docs/meshing/skill/sharp-wedge.png, docs/meshing/skill/sphere-on-plate.png, docs/meshing/skill/spline-vase.png, docs/meshing/skill/stepped-block.png, docs/meshing/skill/thin-ribs.png, docs/meshing/skill/thin-wall-enclosure.png, docs/meshing/skill/upright-bracket.png | FieldCraft contributors | Original: section pictures of the corpus parts and samples meshed by the meshing skill, drawn by `fieldcraft-cli snapshot` (`cargo xtask skill-results`, docs/meshing/skill-results.md) | MIT OR Apache-2.0 | | docs/meshing/skill/analytic-cylinder.png, docs/meshing/skill/analytic-pipe.png, docs/meshing/skill/analytic-plate-hole.png, docs/meshing/skill/analytic-sphere.png, docs/meshing/skill/analytic-torus.png, docs/meshing/skill/bent-tube.png, docs/meshing/skill/bolted-assembly.png, docs/meshing/skill/cantilever-i-beam.png, docs/meshing/skill/crank-arm.png, docs/meshing/skill/cup.png, docs/meshing/skill/filleted-bracket-blocks.png, docs/meshing/skill/filleted-bracket.png, docs/meshing/skill/i-beam.png, docs/meshing/skill/loft-transition.png, docs/meshing/skill/perforated-plate.png, docs/meshing/skill/pi5-base.png, docs/meshing/skill/pi5-lid.png, docs/meshing/skill/pinhole-block.png, docs/meshing/skill/plain-cup.png, docs/meshing/skill/rounded-box.png, docs/meshing/skill/shallow-groove.png, docs/meshing/skill/sharp-wedge.png, docs/meshing/skill/sphere-on-plate.png, docs/meshing/skill/spline-vase.png, docs/meshing/skill/stepped-block.png, docs/meshing/skill/thin-ribs.png, docs/meshing/skill/thin-wall-enclosure.png, docs/meshing/skill/upright-bracket.png | FieldCraft contributors | Original: section pictures of the corpus parts and samples meshed by the meshing skill, drawn by `fieldcraft-cli snapshot` (`cargo xtask skill-results`, docs/meshing/skill-results.md) | MIT OR Apache-2.0 |
| docs/media/examples/flow-past.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the flow-past sample (its bracket; the viewport does not draw flow results yet) (docs/examples.md) | MIT OR Apache-2.0 | | docs/media/examples/flow-past.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the flow-past sample before solving (its bracket as the sample opens) (docs/examples.md) | MIT OR Apache-2.0 |
| docs/media/examples/cooling-enclosure.png | FieldCraft contributors | Original: the natural-convection sealed-enclosure cooling study rendered by FieldCraft (`ui.render`, light theme), generated by `tools/cooling/make.py`; geometry is the committed `examples/cooling/enclosure.solidcraft.json` recipe | MIT OR Apache-2.0 |
| docs/media/examples/cooling-heat-sink.png | FieldCraft contributors | Original: the forced-convection heat-sink cooling study rendered by FieldCraft (`ui.render`, light theme), generated by `tools/cooling/make.py`; geometry is the committed `examples/cooling/heatsink.solidcraft.json` recipe | MIT OR Apache-2.0 |
| docs/media/examples/flow-past-flow.png | FieldCraft contributors | Original: the solved flow-past sample rendered by FieldCraft (`ui.render`, light theme), velocity on the mid-slice with the bracket and upstream streamlines, generated by `tools/flow-clip/make.py` | MIT OR Apache-2.0 |
| docs/media/examples/flow-past-transient.mp4 | FieldCraft contributors | Original: the flow-past start-up transient rendered frame by frame by FieldCraft (`ui.render`), generated by `tools/flow-clip/make.py` (captions drawn by `tools/demo-video/caption.py` in Inter, OFL, crates/ui-egui/fonts) | MIT OR Apache-2.0 |
| docs/media/examples/cantilever.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved cantilever sample (docs/examples.md) | MIT OR Apache-2.0 | | docs/media/examples/cantilever.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved cantilever sample (docs/examples.md) | MIT OR Apache-2.0 |
| docs/media/examples/impact.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved impact sample (docs/examples.md) | MIT OR Apache-2.0 | | docs/media/examples/impact.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved impact sample (docs/examples.md) | MIT OR Apache-2.0 |
| docs/media/examples/ibeam.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved ibeam sample (docs/examples.md) | MIT OR Apache-2.0 | | docs/media/examples/ibeam.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved ibeam sample (docs/examples.md) | MIT OR Apache-2.0 |
@@ -230,6 +234,7 @@ is committed and listed here with its exports.
| .github/workflows/release.yml, .github/release-notes.md, docs/releasing.md | SolidCraft `.github/workflows/release.yml`, `.github/release-notes.md`, `docs/releasing.md`; VectorCraft `.github/workflows/release.yml` | MIT OR Apache-2.0 | | .github/workflows/release.yml, .github/release-notes.md, docs/releasing.md | SolidCraft `.github/workflows/release.yml`, `.github/release-notes.md`, `docs/releasing.md`; VectorCraft `.github/workflows/release.yml` | MIT OR Apache-2.0 |
| .github/workflows/packaging-lint.yml | SolidCraft `.github/workflows/packaging-lint.yml` (itself adapted from PhotoCraft, https://github.com/storytold/photocraft, Apache-2.0) | MIT OR Apache-2.0 | | .github/workflows/packaging-lint.yml | SolidCraft `.github/workflows/packaging-lint.yml` (itself adapted from PhotoCraft, https://github.com/storytold/photocraft, Apache-2.0) | MIT OR Apache-2.0 |
| .github/workflows/arch-check.yml (the riscv64 cross build) | SolidCraft `.github/workflows/ci.yml` (`riscv64-gates`) | MIT OR Apache-2.0 | | .github/workflows/arch-check.yml (the riscv64 cross build) | SolidCraft `.github/workflows/ci.yml` (`riscv64-gates`) | MIT OR Apache-2.0 |
| .github/workflows/claim.yml, .github/workflows/triage.yml, scripts/claim/, scripts/triage/ (the claim and triage bots and their tests) | SolidCraft `.github/workflows/claim.yml`, `triage.yml`, `scripts/claim/`, `scripts/triage/` | MIT OR Apache-2.0 |
## Test oracles ## Test oracles
+38 -4
View File
@@ -61,12 +61,46 @@ Every change starts from an issue, and only the maintainer merges.
1. Pick an open issue labelled **help wanted** or **good first issue** that is not labelled 1. Pick an open issue labelled **help wanted** or **good first issue** that is not labelled
**claimed**. If what you want to do has no issue, open one first and wait for a reply. **claimed**. If what you want to do has no issue, open one first and wait for a reply.
2. Comment that you would like to take it. The maintainer assigns it to you and labels it 2. Comment `/claim` on it. The bot assigns it to you, labels it **claimed** and replies; then it is
**claimed**; then it is yours. Work on unassigned issues may not be accepted. yours. It refuses an issue someone else holds (naming them and when their claim lapses), an
3. Open a draft pull request early, linked with `Closes #N`. An issue with no visible progress issue an open pull request already covers, and an issue without **help wanted** or **good
for 14 days goes back to the pool. first issue**: on those, say what you would like to do and the maintainer replies. Work on
unassigned issues may not be accepted.
3. Open a draft pull request early, linked with `Closes #N`. A pull request that references an
open-for-contributors issue nobody holds (`Closes #N` in the body, `(#N)` in the title) claims
it the same way; if someone holds it already, or an older open pull request references it,
the bot says so on your pull request instead.
4. An issue with no visible progress for 14 days goes back to the pool: a daily sweep frees a claim
with no linked pull request 14 days after it was made, and one with a pull request after 14
days without pushes. `/unclaim` releases it early (you or a maintainer).
Issues labelled **in progress** are being worked on by the core team; please don't start on them. Issues labelled **in progress** are being worked on by the core team; please don't start on them.
The bot never lets them be claimed, and never frees an issue a maintainer holds.
## Labels and triage
The triage bot labels every new issue and pull request (`.github/workflows/triage.yml`). It only
adds labels; correct them freely and it won't put them back.
- **Pull requests** get `area:*` from the paths they change: `crates/mesh` → `area:mesh`,
`crates/lbm*` → `area:cfd`, `crates/solver`, `crates/fem`, `crates/explicit` → `area:solver`,
`crates/ui-egui` and `apps/` → `area:ui`, `crates/io`, `crates/decks` → `area:io`, `docs/` and
top-level `*.md` → `area:docs`, `.github/`, `xtask/`, `packaging/`, `scripts/`, `tools/` →
`area:infra`.
- **Issues** get `bug` or `enhancement` from the form they were filed with (or a crash or
"Feature request" title), and an `area:*` from words in the title or the command an in-app
report names. Until it has a priority, an issue carries `needs-triage`; adding a `priority:`
label removes it.
- **Maintainers** comment `/triage area:mesh high S` to sort an issue or pull request: any
existing label (case, and `-` or `_` for a space, don't matter: `in-progress`), `high` for
`priority: high`, `S`/`M`/`L` for a size (which replaces the old one), `-label` removes one.
Any `/triage` with a change clears `needs-triage`.
- **Short bug reports without steps to reproduce** get `needs-info` and one comment asking for
the steps (and the version, when it's missing). The reporter's reply, or an edit that adds the
steps, removes the label; an issue that stays in `needs-info` for 14 days is closed, and can be
reopened any time. Feature requests, reports filed from the app (**Help › Report a Bug…**),
and maintainers' issues are never asked.
- `needs-triage` is the queue: [open issues with it](https://github.com/tangency-labs/fieldcraft/issues?q=is%3Aopen+label%3Aneeds-triage).
## Reporting a problem or an idea ## Reporting a problem or an idea
+11 -1
View File
@@ -50,7 +50,7 @@ pressure) is a degree of freedom of the existing DOF framework, not a separate s
| M21 Biomechanics and biomaterials | Soft tissue: Holzapfel–Gasser–Ogden fibre-reinforced hyperelasticity with dispersion, Fung, transversely isotropic models; biphasic and poro-hyperelastic cartilage (M19); active muscle (Hill-type); hydrogel swelling. Bone: density-dependent moduli from CT data, strain-energy-density remodelling (Huiskes, Beaupré). Growth and remodelling by kinematic growth (F = Fe·Fg). Implants and biomaterials (titanium, PEEK, UHMWPE) from the M17 library. Verified against published experiments with their tolerances | planned | | M21 Biomechanics and biomaterials | Soft tissue: Holzapfel–Gasser–Ogden fibre-reinforced hyperelasticity with dispersion, Fung, transversely isotropic models; biphasic and poro-hyperelastic cartilage (M19); active muscle (Hill-type); hydrogel swelling. Bone: density-dependent moduli from CT data, strain-energy-density remodelling (Huiskes, Beaupré). Growth and remodelling by kinematic growth (F = Fe·Fg). Implants and biomaterials (titanium, PEEK, UHMWPE) from the M17 library. Verified against published experiments with their tolerances | planned |
| M22 Design studies | Parameters of the SolidCraft design driven from FieldCraft: parametric sweeps, design of experiments, response surfaces and sensitivities; topology optimisation (SIMP with filters, manufacturing constraints); size and shape optimisation; submodelling (driven boundaries from a global model); uncertainty quantification | planned | | M22 Design studies | Parameters of the SolidCraft design driven from FieldCraft: parametric sweeps, design of experiments, response surfaces and sensitivities; topology optimisation (SIMP with filters, manufacturing constraints); size and shape optimisation; submodelling (driven boundaries from a global model); uncertainty quantification | planned |
| M23 Wave propagation and ultrasound | Elastic, acoustic and coupled waves from the explicit solver's 1D bar today to guided and ultrasonic waves, by two methods. Finite differences first, for fast 2D (then 3D) ultrasound on regular grids: staggered-grid velocity–stress FDTD (Virieux) and the elastodynamic finite integration technique for solids, the rotated staggered grid for anisotropic media, a k-space pseudospectral scheme for weakly heterogeneous tissue and water, all on grids voxelised from the face-tagged geometry by the LBM's exact voxeliser, with heterogeneous, layered and fluid–solid media, CPML boundaries, threads and the GPU backend; staircased curved boundaries scatter spuriously, so the FD grid is checked against the finite elements on curved parts. Finite elements for curved geometry and coupling with structures: spectral elements (high-order Gauss–Lobatto–Legendre hexes with diagonal mass) on the hex meshes, and a mesh check in elements per wavelength with the dispersion error it implies; absorbing boundaries (convolutional PML, absorbing layers with increasing damping) and infinite media; tone-burst sources (Hann-windowed cycles), point, line and surface excitation, and piezoelectric transducers (M19); anisotropic media (composites, crystals) and viscoelastic, power-law-in-frequency attenuation; fluid–solid interfaces and fluid-loaded plates; ultrasound in water and tissue, nonlinear acoustics (Westervelt, KZK) for focused ultrasound. Guided waves: dispersion curves by semi-analytical finite elements (SAFE) for plates, pipes, rails and layered and anisotropic laminates — Lamb, Rayleigh, Love and SH modes — with phase and group velocities and mode shapes; mode-selective excitation. Output: A-scans and B-scans at sensors, wavefield snapshots, 2D FFT (frequency–wavenumber) for mode identification, and time–frequency diagrams of any signal — short-time Fourier spectrograms, continuous wavelet transforms (Morlet), reassigned spectrograms and smoothed pseudo Wigner–Ville — with the SAFE or Rayleigh–Lamb group-velocity curves overlaid as arrival times, and group velocities extracted from the ridges. GPU paths (M12's backend) for long runs. Verified by Lamb's problem (point load on a half-space), Rayleigh-wave speed, Rayleigh–Lamb dispersion, Love waves in a layer over a half-space, reflection and transmission at interfaces, and published guided-wave measurements | planned | | M23 Wave propagation and ultrasound | Elastic, acoustic and coupled waves from the explicit solver's 1D bar today to guided and ultrasonic waves, by two methods. Finite differences first, for fast 2D (then 3D) ultrasound on regular grids: staggered-grid velocity–stress FDTD (Virieux) and the elastodynamic finite integration technique for solids, the rotated staggered grid for anisotropic media, a k-space pseudospectral scheme for weakly heterogeneous tissue and water, all on grids voxelised from the face-tagged geometry by the LBM's exact voxeliser, with heterogeneous, layered and fluid–solid media, CPML boundaries, threads and the GPU backend; staircased curved boundaries scatter spuriously, so the FD grid is checked against the finite elements on curved parts. Finite elements for curved geometry and coupling with structures: spectral elements (high-order Gauss–Lobatto–Legendre hexes with diagonal mass) on the hex meshes, and a mesh check in elements per wavelength with the dispersion error it implies; absorbing boundaries (convolutional PML, absorbing layers with increasing damping) and infinite media; tone-burst sources (Hann-windowed cycles), point, line and surface excitation, and piezoelectric transducers (M19); anisotropic media (composites, crystals) and viscoelastic, power-law-in-frequency attenuation; fluid–solid interfaces and fluid-loaded plates; ultrasound in water and tissue, nonlinear acoustics (Westervelt, KZK) for focused ultrasound. Guided waves: dispersion curves by semi-analytical finite elements (SAFE) for plates, pipes, rails and layered and anisotropic laminates — Lamb, Rayleigh, Love and SH modes — with phase and group velocities and mode shapes; mode-selective excitation. Output: A-scans and B-scans at sensors, wavefield snapshots, 2D FFT (frequency–wavenumber) for mode identification, and time–frequency diagrams of any signal — short-time Fourier spectrograms, continuous wavelet transforms (Morlet), reassigned spectrograms and smoothed pseudo Wigner–Ville — with the SAFE or Rayleigh–Lamb group-velocity curves overlaid as arrival times, and group velocities extracted from the ridges. GPU paths (M12's backend) for long runs. Verified by Lamb's problem (point load on a half-space), Rayleigh-wave speed, Rayleigh–Lamb dispersion, Love waves in a layer over a half-space, reflection and transmission at interfaces, and published guided-wave measurements | planned |
| Continuous: verification and validation | Every capability checked against theory (closed forms), experiments (published measurements) and published benchmarks (NAFEMS, vendor benchmark and verification manuals such as Abaqus's), each case our own model in `tests/oracle/` with its cited reference and tolerance; `cargo xtask oracle` runs them and writes docs/oracle.md; once a case matches, our result is frozen as a fixture so fidelity cannot regress (docs/verification.md). Runner, fixtures and CI job: done; 62 theory cases (patch tests of every solid element, cantilevers, Lamé in 3D, plane strain and axisymmetric, Kirsch in 3D and plane stress, rotating disc and cylinder, beam frequencies, couplings, explicit waves, impact and J2 plasticity, CDT invariants), 11 published benchmarks (the MacNeal–Harder solid tests, the Scordelis–Lo roof with solids and with shells, the pinched cylinder, NAFEMS LE1 in 3D and 2D, LE10, FV32) and 8 pending cases with citations (the pinched hemisphere and twisted beam shell meshes, thermal, Cook's membrane, Taylor bar, CFD, explicit Hertz contact); `mesh.block` mapped blocks and 2D patches build their meshes, curved shell patches are written node by node | started | | Continuous: verification and validation | Every capability checked against theory (closed forms), experiments (published measurements) and published benchmarks (NAFEMS, vendor benchmark and verification manuals such as Abaqus's), each case our own model in `tests/oracle/` with its cited reference and tolerance; `cargo xtask oracle` runs them and writes docs/oracle.md; once a case matches, our result is frozen as a fixture so fidelity cannot regress (docs/verification.md). Runner, fixtures and CI job: done; 62 theory cases (patch tests of every solid element, cantilevers, Lamé in 3D, plane strain and axisymmetric, Kirsch in 3D and plane stress, rotating disc and cylinder, beam frequencies, couplings, explicit waves, impact and J2 plasticity, CDT invariants), 15 published benchmarks (the MacNeal–Harder solid tests, the Scordelis–Lo roof with solids and with shells, the pinched cylinder, NAFEMS LE1 in 3D and 2D, LE2, LE3, LE5, LE7, LE10, FV32) and 8 pending cases with citations (the pinched hemisphere and twisted beam shell meshes, thermal, Cook's membrane, Taylor bar, CFD, explicit Hertz contact); `mesh.block` mapped blocks and 2D patches build their meshes, curved shell patches are written node by node | started |
| Continuous: performance | CPU first, deterministic: results never depend on the thread count (fixed partitions, sums in a fixed order; checked by hashing results on 1, 2 and 7 threads). Supernodal left-looking Cholesky (11–15× faster than the column solver on one thread), parallel element matrices, stresses and Lanczos products, explicit element updates and LBM cell blocks on rayon threads (done — `fieldcraft-parallel`, `threads` on the solve commands, `FIELDCRAFT_THREADS`); parallel forward/back substitution and explicit tet4 assembly; GPU paths later, within a stated tolerance of the CPU | started | | Continuous: performance | CPU first, deterministic: results never depend on the thread count (fixed partitions, sums in a fixed order; checked by hashing results on 1, 2 and 7 threads). Supernodal left-looking Cholesky (11–15× faster than the column solver on one thread), parallel element matrices, stresses and Lanczos products, explicit element updates and LBM cell blocks on rayon threads (done — `fieldcraft-parallel`, `threads` on the solve commands, `FIELDCRAFT_THREADS`); parallel forward/back substitution and explicit tet4 assembly; GPU paths later, within a stated tolerance of the CPU | started |
| Continuous: desktop and agents | Desktop app (viewport, model tree, commands, control channel, the open design drawn, `ui.capture` of the whole window offscreen: done; toolbar of every capability from the catalogue, ready or coming soon with its milestone, icons drawn with VectorCraft, docs/features.md: done; the Craft-family shell — title bar, workspaces and ribbon drop-downs, browser, context panel, command search, journal timeline, light and dark themes — native file pickers with background opening, drag and drop and recent files, samples and one-click Solve, and an examples gallery of ready-to-solve samples, each checked by a test (docs/examples.md): done; MCP server over stdio, headless or bridged to the app, with read and write roots, docs/mcp.md: done; caps labels in Inter SemiBold; the README's analysis video recorded through MCP by `tools/demo-video`: done; support, load, reference-point and coupling symbols in the viewport and headless renders, with their browser toggles: done; symbols as wireframes, one mark per constrained degree of freedom, a constant size on screen with a size setting, highlighted with their selected definition, concentrated loads drawn over the part: done; selection by picking in the viewport — geometry faces, edges, vertices and bodies, mesh nodes, element faces and elements, box selection, hover, a Target row in command forms, `ui.select`/`ui.selection` and MCP `select`: done; a camera parameter for screenshots, a ribbon that follows the command being run: in progress; the browser build — the same app in a web page with browser file pickers, downloads, drops and autosave to browser storage, deployed to GitHub Pages from each release, docs/web.md: done; in-app updates — a daily check of GitHub Releases, Help › Check for Updates and Reinstall, downloads verified against the release key's signature of SHA256SUMS.txt and swapped in after closing, `app.update.check`, `fieldcraft-cli update`, Reload to update in the browser, docs/updates.md: done), command schema as the public API | started | | Continuous: desktop and agents | Desktop app (viewport, model tree, commands, control channel, the open design drawn, `ui.capture` of the whole window offscreen: done; toolbar of every capability from the catalogue, ready or coming soon with its milestone, icons drawn with VectorCraft, docs/features.md: done; the Craft-family shell — title bar, workspaces and ribbon drop-downs, browser, context panel, command search, journal timeline, light and dark themes — native file pickers with background opening, drag and drop and recent files, samples and one-click Solve, and an examples gallery of ready-to-solve samples, each checked by a test (docs/examples.md): done; MCP server over stdio, headless or bridged to the app, with read and write roots, docs/mcp.md: done; caps labels in Inter SemiBold; the README's analysis video recorded through MCP by `tools/demo-video`: done; support, load, reference-point and coupling symbols in the viewport and headless renders, with their browser toggles: done; symbols as wireframes, one mark per constrained degree of freedom, a constant size on screen with a size setting, highlighted with their selected definition, concentrated loads drawn over the part: done; selection by picking in the viewport — geometry faces, edges, vertices and bodies, mesh nodes, element faces and elements, box selection, hover, a Target row in command forms, `ui.select`/`ui.selection` and MCP `select`: done; a camera parameter for screenshots, a ribbon that follows the command being run: in progress; the browser build — the same app in a web page with browser file pickers, downloads, drops and autosave to browser storage, deployed to GitHub Pages from each release, docs/web.md: done; in-app updates — a daily check of GitHub Releases, Help › Check for Updates and Reinstall, downloads verified against the release key's signature of SHA256SUMS.txt and swapped in after closing, `app.update.check`, `fieldcraft-cli update`, Reload to update in the browser, docs/updates.md: done), command schema as the public API | started |
@@ -130,3 +130,13 @@ Temperature is available on fluid slices, obstacle surfaces and streamline colou
saved frames carry it. Signed face-average wall heat follows recorded histories and the saved frames carry it. Signed face-average wall heat follows recorded histories and the
model's power/area units. Commands, the Results panel and headless pictures share the view; model's power/area units. Commands, the Results panel and headless pictures share the view;
initial frames without recorded wall heat remain uncoloured. initial frames without recorded wall heat remain uncoloured.
### Flow start-up transient and gallery clip (#164)
`results.animate` now plays a fluid step's frames in order, the start-up transient from the
uniform initial field to the settled flow, the way an explicit step plays its frames; the fluid Results
panel gains an Animate control. The `flow-past` gallery sample gains a clip and a solved still of
the air flowing past the L-bracket, regenerated by `tools/flow-clip/make.py` (the app rendered
frame by frame over the control channel, encoded with ffmpeg). The wake forms and the drag
settles; it does not shed (that needs a finer grid or a collision operator stable near τ⁺ = 0.5,
#557).
+9 -6
View File
@@ -44,7 +44,7 @@ pub const MODES: CommandSpec = CommandSpec {
pub const ANIMATE: CommandSpec = CommandSpec { pub const ANIMATE: CommandSpec = CommandSpec {
id: "results.animate", id: "results.animate",
label: "Animate", label: "Animate",
params: r#"{"on": true} — animates the deformed shape: a mode swings through its positive and negative shape, a static step grows from undeformed to deformed"#, params: r#"{"on": true} — animates the result over time: a mode swings through its positive and negative shape, a static step grows from undeformed to deformed, an explicit or fluid step plays its frames in order"#,
form: Some(super::forms::ANIMATE), form: Some(super::forms::ANIMATE),
kind: CommandKind::Session, kind: CommandKind::Session,
requires: &[Requirement::Result], requires: &[Requirement::Result],
@@ -341,12 +341,12 @@ fn animate(s: &mut Session, p: &Value) -> Result<Value> {
return Ok(s.results_report()); return Ok(s.results_report());
} }
let step = step_for(s, &json!({}), Want::Any)?; let step = step_for(s, &json!({}), Want::Any)?;
if s.latest_result(&step).is_some_and(|(r, _)| matches!(r.kind(), Kind::Fluid(_))) { let fluid = s.latest_result(&step).is_some_and(|(r, _)| matches!(r.kind(), Kind::Fluid(_)));
return Err(EngineError::Params(format!("step `{step}` is a fluid step: nothing deforms; show its frames with results.frame")));
}
let v = view_of(s, &step)?; let v = view_of(s, &step)?;
v.animate = true; v.animate = true;
v.deformed = true; // A fluid step has nothing to deform; animating plays its frames (as results.frame does) in
// order. A structural step animates its deformation (or an explicit step its frames).
v.deformed = !fluid;
Ok(s.results_report()) Ok(s.results_report())
} }
@@ -1484,7 +1484,10 @@ mod tests {
assert_eq!((r["traced"]["seeds"].as_u64(), r["traced"]["points"].as_u64()), (Some(5), Some(20)), "{r}"); assert_eq!((r["traced"]["seeds"].as_u64(), r["traced"]["points"].as_u64()), (Some(5), Some(20)), "{r}");
assert!(s.execute("results.streamlines", &json!({"show": false})).unwrap()["streamlines"].is_null()); assert!(s.execute("results.streamlines", &json!({"show": false})).unwrap()["streamlines"].is_null());
assert_eq!(s.execute("results.surface", &json!({"show": false})).unwrap()["surface"], false); assert_eq!(s.execute("results.surface", &json!({"show": false})).unwrap()["surface"], false);
assert!(s.execute("results.animate", &json!({})).is_err(), "nothing deforms"); // A fluid step animates its frames (the start-up transient), not a deformation.
let r = s.execute("results.animate", &json!({})).unwrap();
assert_eq!((r["view"]["animate"].as_bool(), r["view"]["deformed"].as_bool()), (Some(true), Some(false)), "{r}");
assert!(s.execute("results.animate", &json!({"on": false})).unwrap()["view"]["animate"] == json!(false));
assert_eq!((s.journal.len(), s.revision()), (journal, revision), "display commands are not edits"); assert_eq!((s.journal.len(), s.revision()), (journal, revision), "display commands are not edits");
// Structural steps have none of it. // Structural steps have none of it.
let mut t = solved(); let mut t = solved();
+6 -1
View File
@@ -110,7 +110,7 @@ pub const FLUID: CommandSpec = CommandSpec {
pub const PROBE: CommandSpec = CommandSpec { pub const PROBE: CommandSpec = CommandSpec {
id: "solve.probe", id: "solve.probe",
label: "Probe Results", label: "Probe Results",
params: r#"{"step": "Load", "nodes": [1, 2] (or "set": "fillet", a set's nodes), "mode": 1, "frame": 0} — the stored result at nodes (with `max_von_mises`, the largest among them, for static steps): displacement, rotation, reaction, reaction moment, stress and von Mises (static, with `elements` each element's results: solid, 2D and truss nodal stresses (and, in a nonlinear step, the equivalent plastic strain at the integration points of a plastic element), shell section forces (N, M, Q per length in the element's axes) and bottom, middle and top stresses, beam end forces (N, V_y, V_z, T, M_y, M_z in its axes)), the shape of mode `mode` with its rotations (frequency, from 1), or displacement and velocity in field frame `frame` (explicit, from 0; the last by default) at `nodes` (or at the nodes nearest to points: "at": [[x, y, z], ...], lowest label on a tie, each entry then giving its `xyz` and `distance`) and, with `elements` (explicit), each element's stress, von Mises stress, strain, equivalent plastic strain, damage, temperature and whether it has eroded; a coupled reference point's label gives its six displacements (translations, rotations) and reactions (forces, moments). Fluid steps: {"step": "Flow", "points": [[x, y, z]], "frame": 10 (default the last)} gives velocity, pressure and (with heat) temperature in the cells at the points; {"step": "Flow", "forces": ["x_min", "F2:top", "Body1/F2:top"]} gives those walls' force histories and {"step": "Flow", "heat": ["y_min"]} the heat into them over time (power, positive into the wall)"#, params: r#"{"step": "Load", "nodes": [1, 2] (or "set": "fillet", a set's nodes), "mode": 1, "frame": 0} — the stored result at nodes (with `max_von_mises`, the largest among them, for static steps): displacement, rotation, reaction, reaction moment, stress, principal stresses (the values, largest first, without their directions) and von Mises (static, with `elements` each element's results: solid, 2D and truss nodal stresses (and, in a nonlinear step, the equivalent plastic strain at the integration points of a plastic element), shell section forces (N, M, Q per length in the element's axes) and bottom, middle and top stresses, beam end forces (N, V_y, V_z, T, M_y, M_z in its axes)), the shape of mode `mode` with its rotations (frequency, from 1), or displacement and velocity in field frame `frame` (explicit, from 0; the last by default) at `nodes` (or at the nodes nearest to points: "at": [[x, y, z], ...], lowest label on a tie, each entry then giving its `xyz` and `distance`) and, with `elements` (explicit), each element's stress, von Mises stress, strain, equivalent plastic strain, damage, temperature and whether it has eroded; a coupled reference point's label gives its six displacements (translations, rotations) and reactions (forces, moments). Fluid steps: {"step": "Flow", "points": [[x, y, z]], "frame": 10 (default the last)} gives velocity, pressure and (with heat) temperature in the cells at the points; {"step": "Flow", "forces": ["x_min", "F2:top", "Body1/F2:top"]} gives those walls' force histories and {"step": "Flow", "heat": ["y_min"]} the heat into them over time (power, positive into the wall)"#,
form: Some(super::forms::SOLVE_PROBE), form: Some(super::forms::SOLVE_PROBE),
kind: CommandKind::Model, kind: CommandKind::Model,
requires: &[Requirement::Result], requires: &[Requirement::Result],
@@ -717,6 +717,7 @@ fn probe_implicit(result: &ImplicitResult, step: &str, nodes: &[u32], p: &Value)
"reaction": r.reaction.get(i), "reaction": r.reaction.get(i),
"reaction_moment": r.reaction_moment.get(i), "reaction_moment": r.reaction_moment.get(i),
"stress": r.stress.get(i), "stress": r.stress.get(i),
"principal": r.stress.get(i).map(fieldcraft_fem::principal),
"von_mises": r.von_mises.get(i), "von_mises": r.von_mises.get(i),
"temperature": r.temperature.as_ref().and_then(|t| t.get(i)), "temperature": r.temperature.as_ref().and_then(|t| t.get(i)),
}); });
@@ -945,6 +946,10 @@ pub(crate) mod tests {
let probe = s.execute("solve.probe", &json!({"step": "Bend", "nodes": [11, 1]})).unwrap(); let probe = s.execute("solve.probe", &json!({"step": "Bend", "nodes": [11, 1]})).unwrap();
assert!(probe["nodes"][0]["displacement"][2].as_f64().unwrap() < -3.8, "{probe}"); assert!(probe["nodes"][0]["displacement"][2].as_f64().unwrap() < -3.8, "{probe}");
assert!(probe["nodes"][1]["reaction"][2].as_f64().unwrap() != 0.0, "{probe}"); assert!(probe["nodes"][1]["reaction"][2].as_f64().unwrap() != 0.0, "{probe}");
// The principal stresses of the nodal stress, largest first.
let root = &probe["nodes"][1];
let stress: [f64; 6] = serde_json::from_value(root["stress"].clone()).unwrap();
assert_eq!(root["principal"], json!(fieldcraft_fem::principal(&stress)), "{root}");
// By location: the nearest node, with where it is. // By location: the nearest node, with where it is.
let at = s.execute("solve.probe", &json!({"step": "Bend", "at": [[10.1, -0.2, 0.1], [0, 0, 0]]})).unwrap(); let at = s.execute("solve.probe", &json!({"step": "Bend", "at": [[10.1, -0.2, 0.1], [0, 0, 0]]})).unwrap();
assert_eq!((at["nodes"][0]["node"].clone(), at["nodes"][1]["node"].clone()), (json!(11), json!(1)), "{at}"); assert_eq!((at["nodes"][0]["node"].clone(), at["nodes"][1]["node"].clone()), (json!(11), json!(1)), "{at}");
+134 -1
View File
@@ -355,6 +355,34 @@ pub fn plot_with_surfaces(
bodies: &[&str], bodies: &[&str],
) -> Result<FlowPlot> { ) -> Result<FlowPlot> {
let mut out = plot(view, result, traced)?; let mut out = plot(view, result, traced)?;
if view.contour == Some(Field::Temperature)
&& let Kind::Fluid(r) = result.kind()
{
// An obstacle face held at a temperature is drawn at it: sampling the fluid beside it
// would mix in the cooler cells further out.
let held = held_temperatures(session, r, base, bodies);
if held.iter().any(Option::is_some) {
if let Some(c) = &mut out.contour {
// A held face beyond the fluid's range is the extreme: the legend, its Max/Min
// lines and the viewport's marker all name it. Its label continues past the
// cells, into `wall_positions` (see [`FlowPlot::wall_positions`]).
let cells = c.values.len();
for (t, v) in base.surface_tris.iter().zip(&held) {
let Some(v) = *v else { continue };
let at = || (t.p[0] + t.p[1] + t.p[2]) * (1.0 / 3.0);
if v > c.max.1 {
c.max = (cells + out.wall_positions.len(), v);
out.wall_positions.push(Some(at()));
} else if v < c.min.1 {
c.min = (cells + out.wall_positions.len(), v);
out.wall_positions.push(Some(at()));
}
}
c.bands = view.bands(c.min.1, c.max.1);
}
out.wall_values = base.surface_tris.iter().zip(held).map(|(t, v)| (t.p, v)).collect();
}
}
if view.contour == Some(Field::WallHeatFlux) { if view.contour == Some(Field::WallHeatFlux) {
let Kind::Fluid(r) = result.kind() else { return Ok(out) }; let Kind::Fluid(r) = result.kind() else { return Ok(out) };
let faces = wall::faces(session, r, view.pick())?; let faces = wall::faces(session, r, view.pick())?;
@@ -372,6 +400,52 @@ pub fn plot_with_surfaces(
Ok(out) Ok(out)
} }
/// Per displayed surface triangle, the temperature its obstacle face is held at, when the step's
/// domain holds that face at one and the result is of the current model.
fn held_temperatures(session: &crate::Session, r: &FluidResult, base: &fieldcraft_render::DisplayMesh, bodies: &[&str]) -> Vec<Option<f64>> {
let faces = held_faces(session, r);
let held: std::collections::BTreeMap<_, _> = faces.iter().map(|h| ((h.body.as_str(), h.index), h.value)).collect();
base.surface_tris.iter().map(|t| bodies.get(t.body as usize).and_then(|name| held.get(&(*name, t.face)).copied())).collect()
}
/// An obstacle face the step's fluid domain holds at a temperature.
pub struct HeldFace {
pub body: String,
pub face: String,
pub index: u32,
pub value: f64,
}
/// The faces the step's fluid domain holds at a temperature, when the result is of the current
/// model (a stale result was solved with other conditions).
pub fn held_faces(session: &crate::Session, r: &FluidResult) -> Vec<HeldFace> {
if session.latest_result(&r.step).is_some_and(|(_, current)| !current) {
return Vec::new();
}
let Some(thermal) = session
.model
.step(&r.step)
.and_then(|st| st.procedure.fluid_domain())
.and_then(|d| session.model.fluid_domain(d))
.and_then(|d| d.thermal.as_ref())
else {
return Vec::new();
};
let design = session.design.as_deref();
let mut out = Vec::new();
for sb in &thermal.surfaces {
let (fieldcraft_model::ThermalCondition::Temperature { value }, fieldcraft_model::Entity::Face(face)) = (&sb.condition, &sb.face.entity)
else {
continue;
};
let index = design.and_then(|d| d.bodies.iter().find(|b| b.name == sb.face.body)).and_then(|b| b.face_index(face));
if let Some(index) = index {
out.push(HeldFace { body: sb.face.body.clone(), face: face.clone(), index, value: *value });
}
}
out
}
/// The colours a flow plot is drawn in. /// The colours a flow plot is drawn in.
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
pub struct FlowColors<'a> { pub struct FlowColors<'a> {
@@ -401,7 +475,15 @@ pub fn draw(plot: &FlowPlot, scene: &mut Scene, colors: &FlowColors) {
} }
} else if plot.surface { } else if plot.surface {
let model = std::mem::take(&mut scene.tris); let model = std::mem::take(&mut scene.tris);
for t in model { // Faces held at a temperature (`wall_values`, matched by position as for wall heat).
let held = (c.field == Field::Temperature && model.len() == plot.wall_values.len()).then_some(&plot.wall_values);
for (k, t) in model.into_iter().enumerate() {
if let Some((points, Some(v))) = held.and_then(|h| h.get(k))
&& t.p == *points
{
banded(t.p, [*v; 3], t.normal, &c.bands, &color, &mut scene.tris);
continue;
}
let v = t.p.map(|p| flow::sample(&plot.grid, &c.values, p)); let v = t.p.map(|p| flow::sample(&plot.grid, &c.values, p));
let (sum, known) = v.iter().filter(|x| !x.is_nan()).fold((0.0, 0u32), |(s, n), x| (s + x, n + 1)); let (sum, known) = v.iter().filter(|x| !x.is_nan()).fold((0.0, 0u32), |(s, n), x| (s + x, n + 1));
if known == 0 { if known == 0 {
@@ -542,6 +624,57 @@ pub(crate) mod tests {
s s
} }
/// A face held at a temperature is drawn at that temperature, and the legend reaches it,
/// rather than at the mean of the fluid cells around it (which mixes in the cooler ones
/// further out: an 80 °C bracket looked 50 °C).
#[test]
fn a_face_held_at_a_temperature_is_drawn_at_it() {
let mut s = plate_in_a_tank();
let face = s.design.as_ref().unwrap().bodies[0].faces()[0].name.clone();
s.execute(
"model.material.edit",
&json!({"name": "Water", "density": 1e-9, "fluid": {"type": "newtonian", "viscosity": 1e-7}, "thermal": {"conductivity": 400, "specific_heat": 4e9}}),
)
.unwrap();
s.execute(
"model.fluid_domain.edit",
&json!({"name": "Tank", "material": "Water", "min": [-20, -20, -20], "max": [60, 50, 30], "spacing": 5, "obstacles": ["Body1"],
"faces": {"x_min": {"type": "velocity", "velocity": [10, 0, 0]}, "x_max": {"type": "pressure", "pressure": 0}},
"thermal": {"initial_temperature": 20}}),
)
.unwrap();
s.execute(
"model.fluid_domain.thermal_boundary",
&json!({"domain": "Tank", "at": {"body": "Body1", "face": face}, "condition": {"type": "temperature", "value": 80}}),
)
.unwrap();
s.execute("solve.fluid", &json!({})).unwrap();
s.execute("results.surface", &json!({"field": "temperature", "show": true})).unwrap();
let (result, current) = s.latest_result("Flow").unwrap();
assert!(current);
let view = s.results_view.clone().unwrap();
let base = crate::view::display(&s).0;
let names: Vec<&str> = s.design.as_ref().unwrap().bodies.iter().map(|b| b.name.as_str()).collect();
let fp = plot_with_surfaces(&view, result, &s.traced, &s, &base, &names).unwrap();
let c = fp.contour.as_ref().unwrap();
// The fluid never quite reaches the wall's 80 °C; the legend does, and its maximum is the
// held face (a label past the cells, at a point on that face).
assert!(c.values.iter().all(|t| t.is_nan() || *t < 80.0));
assert_eq!((c.max.1, c.bands.max), (80.0, 80.0), "{:?}", c.bands);
let at = c.max.0.checked_sub(c.values.len()).and_then(|k| fp.wall_positions.get(k).copied().flatten());
assert!(at.is_some(), "{:?}", c.max);
let held: Vec<f64> = fp.wall_values.iter().filter_map(|(_, v)| *v).collect();
assert!(!held.is_empty() && held.iter().all(|t| *t == 80.0), "{held:?}");
// A script reads the same maximum as the app shows: the held face.
let reply = s.results_report();
assert_eq!(reply["contour"]["range"]["max"], json!({"body": "Body1", "face": face, "value": 80.0}), "{reply}");
// The headless picture draws the held face in the top band's colour.
let p = crate::view::Palette::LIGHT;
let top = bands_color(&p.contour, p.contour_over, &c.bands, c.bands.band(80.0).unwrap());
let (scene, ..) = crate::view::scene(&s, &p);
assert!(scene.tris.iter().any(|t| t.color == top));
}
#[test] #[test]
fn a_fluid_result_is_first_shown_across_the_flow() { fn a_fluid_result_is_first_shown_across_the_flow() {
let r = channel(); let r = channel();
+12
View File
@@ -722,6 +722,18 @@ impl Session {
out[k.as_str()] = x; out[k.as_str()] = x;
} }
} }
// A face held beyond the fluid's range is the extreme, as the legend shows it.
if v.contour == Some(Field::Temperature) && out["contour"]["range"].is_object() {
for h in crate::flow::held_faces(self, f) {
let end = json!({"body": h.body, "face": h.face, "value": h.value});
let range = &mut out["contour"]["range"];
if range["max"]["value"].as_f64().is_some_and(|m| h.value > m) {
range["max"] = end;
} else if range["min"]["value"].as_f64().is_some_and(|m| h.value < m) {
range["min"] = end;
}
}
}
if v.contour == Some(Field::WallHeatFlux) if v.contour == Some(Field::WallHeatFlux)
&& let Ok(faces) = crate::flow::wall::faces(self, f, v.pick()) && let Ok(faces) = crate::flow::wall::faces(self, f, v.pick())
{ {
@@ -0,0 +1,17 @@
//! Compile a SolidCraft recipe script (`{"commands": [...]}`) into a `.solidcraft` design that
//! `geometry.open` (and so `fieldcraft-cli run`/`snapshot`) can open. Used by the cooling-examples
//! runner (`tools/cooling/make.py`) to build the committed example geometry from its recipe.
//!
//! `cargo run --example compile_recipe -p fieldcraft-geometry --features scripts -- recipe.json out.solidcraft`
fn main() -> Result<(), String> {
let args: Vec<String> = std::env::args().skip(1).collect();
let (recipe, out) = match args.as_slice() {
[recipe, out] => (recipe, out),
_ => return Err("usage: compile_recipe <recipe.json> <out.solidcraft>".into()),
};
let script = std::fs::read_to_string(recipe).map_err(|e| format!("read {recipe}: {e}"))?;
let bytes = fieldcraft_geometry::script_design(&script).map_err(|e| format!("compile: {e}"))?;
std::fs::write(out, &bytes).map_err(|e| format!("write {out}: {e}"))?;
eprintln!("compile_recipe: wrote {out} ({} bytes)", bytes.len());
Ok(())
}
+8 -3
View File
@@ -916,8 +916,8 @@ fn assignable(loops: &[Poly], cuts: &[Vec<Q>], s: &Settings) -> bool {
fn o_grid(loops: &[Poly], s: &Settings, budget: &mut usize) -> Vec<Vec<Q>> { fn o_grid(loops: &[Poly], s: &Settings, budget: &mut usize) -> Vec<Vec<Q>> {
let (Some(outer), Some(hole)) = (loops.first(), loops.get(1)) else { return Vec::new() }; let (Some(outer), Some(hole)) = (loops.first(), loops.get(1)) else { return Vec::new() };
let c = centroid(&hole.pts); let c = centroid(&hole.pts);
let corner = |p: &Poly, i: usize| p.is_end(i) && p.angle(i) <= 0.75 * std::f64::consts::PI; let corner = |p: &Poly, i: usize| p.is_end(i) && region::is_corner(p.angle(i));
let concave = |p: &Poly, i: usize| p.is_end(i) && p.angle(i) >= 1.25 * std::f64::consts::PI; let concave = |p: &Poly, i: usize| p.is_end(i) && region::is_reflex(p.angle(i));
// A cut from loop point (l, i) along d that must end on loop `target`. // A cut from loop point (l, i) along d that must end on loop `target`.
let ray = |l: usize, i: usize, d: Q, target: usize| -> Option<Vec<Q>> { let ray = |l: usize, i: usize, d: Q, target: usize| -> Option<Vec<Q>> {
let o = loops.get(l)?.at(i); let o = loops.get(l)?.at(i);
@@ -1134,8 +1134,9 @@ mod tests {
let n = ((a1 - a0).abs() / 5.0).ceil().max(1.0) as usize; let n = ((a1 - a0).abs() / 5.0).ceil().max(1.0) as usize;
(0..=n) (0..=n)
.map(|k| { .map(|k| {
// The mesher's portable trig, so every platform gets the same points (#556).
let t = (a0 + (a1 - a0) * k as f64 / n as f64).to_radians(); let t = (a0 + (a1 - a0) * k as f64 / n as f64).to_radians();
[c[0] + r * t.cos(), c[1] + r * t.sin()] [c[0] + r * libm::cos(t), c[1] + r * libm::sin(t)]
}) })
.collect() .collect()
} }
@@ -1308,6 +1309,10 @@ mod tests {
]); ]);
let m = mesh_profile(&profile, &[], 2.5); let m = mesh_profile(&profile, &[], 2.5);
check(&m, &profile, 0.5); check(&m, &profile, 0.5);
// The intended O-grid on every platform (#556): Windows' last-bit-different arcs used to
// give 10 regions and 2063 quads with a 0.375 corner.
assert_eq!(m.section.schemes.len(), 9, "{:?}", m.section.schemes);
assert_eq!(m.section.quads.iter().map(Vec::len).sum::<usize>(), 848);
} }
#[test] #[test]
+45 -3
View File
@@ -47,6 +47,24 @@ const MAX_ANNULUS_NODES: usize = 4096;
/// Smoothing sweeps over the interior of a triangle primitive. /// Smoothing sweeps over the interior of a triangle primitive.
const TRIANGLE_SWEEPS: usize = 20; const TRIANGLE_SWEEPS: usize = 20;
/// How close (radians) an interior angle must come to a classification threshold to count as on
/// it. Exact geometry lands on the thresholds (an O-grid's 45° cuts meet straight sides at
/// exactly 135°), and the computed angle is then rounded either way by the last bits of its
/// points: those differ between platforms' `sin`/`cos` and between tessellations, and the
/// scheme must not (#556). An angle within this of a threshold is taken as exactly on it, and
/// on a threshold is smooth, neither a corner nor reflex: that keeps the slot's intended O-grid.
const ANGLE_TOLERANCE: f64 = 1e-9;
/// A corner: an interior angle clearly below 135° (exactly 135° is not one).
pub(crate) fn is_corner(angle: f64) -> bool {
angle < 0.75 * PI - ANGLE_TOLERANCE
}
/// A reflex turn no mapped or triangle region can take: clearly above 225°.
pub(crate) fn is_reflex(angle: f64) -> bool {
angle > 1.25 * PI + ANGLE_TOLERANCE
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Scheme { pub(crate) enum Scheme {
/// Corner positions (curve uses) in the single loop. /// Corner positions (curve uses) in the single loop.
@@ -89,10 +107,10 @@ pub(crate) fn classify(loops: &[Vec<Vec<Q>>]) -> Scheme {
match loops { match loops {
[lp] => { [lp] => {
let Some(a) = angles(lp, ccw(lp)) else { return Scheme::Free }; let Some(a) = angles(lp, ccw(lp)) else { return Scheme::Free };
if a.iter().any(|&x| x >= 1.25 * PI) { if a.iter().any(|&x| is_reflex(x)) {
return Scheme::Free; return Scheme::Free;
} }
let corners: Vec<usize> = (0..a.len()).filter(|&k| a.get(k).is_some_and(|&x| x <= 0.75 * PI)).collect(); let corners: Vec<usize> = (0..a.len()).filter(|&k| a.get(k).is_some_and(|&x| is_corner(x))).collect();
if corners.is_empty() { if corners.is_empty() {
return Scheme::Disk; return Scheme::Disk;
} }
@@ -103,7 +121,7 @@ pub(crate) fn classify(loops: &[Vec<Vec<Q>>]) -> Scheme {
} }
} }
[outer, inner] => { [outer, inner] => {
let smooth = |lp: &Vec<Vec<Q>>| angles(lp, ccw(lp)).is_some_and(|a| a.iter().all(|&x| x > 0.75 * PI && x < 1.25 * PI)); let smooth = |lp: &Vec<Vec<Q>>| angles(lp, ccw(lp)).is_some_and(|a| a.iter().all(|&x| !is_corner(x) && !is_reflex(x)));
if smooth(outer) && smooth(inner) { Scheme::Annulus } else { Scheme::Free } if smooth(outer) && smooth(inner) { Scheme::Annulus } else { Scheme::Free }
} }
_ => Scheme::Free, _ => Scheme::Free,
@@ -568,6 +586,30 @@ mod tests {
use super::*; use super::*;
use crate::size::UniformSize; use crate::size::UniformSize;
/// An angle of exactly 135°, the threshold, is not a corner however the last bits of its
/// points round (Windows' `sin` against glibc's, or two tessellations): a trapezoid with one
/// stays a triangle rather than flipping to mapped when that vertex moves by an ulp (#556).
#[test]
fn an_angle_on_the_threshold_is_not_a_corner_whichever_way_it_rounds() {
// (0, 0) 90°, (3, 0) 45°, (2, 1) 135°, (0, 1) 90°: three corners and a 135° bend.
let trapezoid = |p: Q| {
let pts = [[0.0, 0.0], [3.0, 0.0], p, [0.0, 1.0]];
let curves: Vec<Vec<Q>> = (0..4).map(|k| vec![pts[k], pts[(k + 1) % 4]]).collect();
classify(&[curves])
};
let steps: [fn(f64) -> f64; 3] = [|v| v, f64::next_up, f64::next_down];
for nx in steps {
for ny in steps {
let mut p = [nx(2.0), ny(1.0)];
// A few ulps further each way as well.
for _ in 0..3 {
assert_eq!(trapezoid(p), Scheme::Triangle([0, 1, 3]), "{p:?}");
p = [nx(p[0]), ny(p[1])];
}
}
}
}
fn circle(r: f64, n: usize, ccw: bool) -> Vec<Q> { fn circle(r: f64, n: usize, ccw: bool) -> Vec<Q> {
(0..n) (0..n)
.map(|k| { .map(|k| {
+1 -1
View File
@@ -1115,7 +1115,7 @@ impl ModelApp {
/// frame; `None` when no results are shown. /// frame; `None` when no results are shown.
fn plot_key(&self) -> Option<PlotKey> { fn plot_key(&self) -> Option<PlotKey> {
let (view, result, current) = results::shown(&self.session)?; let (view, result, current) = results::shown(&self.session)?;
let frame = results::frame(view, self.now); let frame = results::frame(view, result, self.now);
let pick = results::pick(view, result, frame); let pick = results::pick(view, result, frame);
Some(PlotKey { revision: self.revision, result: Arc::as_ptr(result) as usize, current, view: view.clone(), pick, frame }) Some(PlotKey { revision: self.revision, result: Arc::as_ptr(result) as usize, current, view: view.clone(), pick, frame })
} }
+53 -10
View File
@@ -118,7 +118,14 @@ impl Plot {
return self.flow.as_ref()?.wall_positions.get(label as usize).copied().flatten(); return self.flow.as_ref()?.wall_positions.get(label as usize).copied().flatten();
} }
match self.result.kind() { match self.result.kind() {
Kind::Fluid(f) => Some(Vec3::from(fieldcraft_engine::flow::cell_centre(f, usize::try_from(label).ok()?))), Kind::Fluid(f) => {
let label = usize::try_from(label).ok()?;
// Past the cells: a face held at a temperature that is the field's extreme.
match label.checked_sub(f.grid.len()) {
Some(k) => self.flow.as_ref()?.wall_positions.get(k).copied().flatten(),
None => Some(Vec3::from(fieldcraft_engine::flow::cell_centre(f, label))),
}
}
_ => self.node_position(label), _ => self.node_position(label),
} }
} }
@@ -136,9 +143,12 @@ pub fn shown(session: &Session) -> Option<(&ResultsView, &Arc<StepResult>, bool)
Some((view, result, current)) Some((view, result, current))
} }
/// The animation frame shown at time `now` (seconds), when animating. /// The animation frame shown at time `now` (seconds), when animating. A deformed step swings its
pub fn frame(view: &ResultsView, now: f64) -> Option<u64> { /// shape; a fluid step plays its frames though it does not deform; a step with nothing to animate
(view.animate && view.deformed && now.is_finite()).then(|| ((now.max(0.0) / PERIOD * FRAMES as f64).floor() as u64) % FRAMES) /// (Animate on but Deformed off, and not fluid) stays still.
pub fn frame(view: &ResultsView, result: &StepResult, now: f64) -> Option<u64> {
let plays = view.deformed || matches!(result.kind(), Kind::Fluid(_));
(view.animate && plays && now.is_finite()).then(|| ((now.max(0.0) / PERIOD * FRAMES as f64).floor() as u64) % FRAMES)
} }
/// The deformation multiplier of an animation frame: a mode swings through ±1, a static step /// The deformation multiplier of an animation frame: a mode swings through ±1, a static step
@@ -161,6 +171,7 @@ pub fn pick(view: &ResultsView, result: &StepResult, frame: Option<u64>) -> Pick
let mut p = view.pick(); let mut p = view.pick();
let frames = match result.kind() { let frames = match result.kind() {
Kind::Explicit(e) => e.frames.len(), Kind::Explicit(e) => e.frames.len(),
Kind::Fluid(f) => f.frames.len(),
Kind::Static(r) => r.nonlinear.as_ref().map_or(0, |h| h.frames.len()), Kind::Static(r) => r.nonlinear.as_ref().map_or(0, |h| h.frames.len()),
_ => 0, _ => 0,
}; };
@@ -474,12 +485,34 @@ pub(crate) mod tests {
let scene = scene(&p, Style::ShadedEdges, crate::scene::Show::DEFAULT, &t); let scene = scene(&p, Style::ShadedEdges, crate::scene::Show::DEFAULT, &t);
assert!(scene.tris.len() > base.surface_tris.len(), "the slice is drawn"); assert!(scene.tris.len() > base.surface_tris.len(), "the slice is drawn");
assert!(!scene.lines.is_empty(), "streamlines and the part's edges are drawn"); assert!(!scene.lines.is_empty(), "streamlines and the part's edges are drawn");
// Pressure on the slice; the first frame (at rest at the inlet speed everywhere). // Pressure on the slice; the first frame. This plate_in_a_tank fixture sets no initial
// velocity, so it starts at rest (unlike the flow-past sample's uniform inlet field).
s.execute("results.slice", &json!({"field": "pressure", "frame": 0})).unwrap(); s.execute("results.slice", &json!({"field": "pressure", "frame": 0})).unwrap();
let p = plot_at(&s, &base, None).unwrap(); let p = plot_at(&s, &base, None).unwrap();
assert_eq!((p.contour.as_ref().map(|c| c.field), p.pick.frame), (Some(Field::Pressure), Some(0))); assert_eq!((p.contour.as_ref().map(|c| c.field), p.pick.frame), (Some(Field::Pressure), Some(0)));
} }
/// Animating a fluid step plays its frames: the clock maps across the frames once per cycle,
/// and results.animate turns it on without deforming the part (the flow start-up transient).
#[test]
fn a_fluid_step_animates_its_frames() {
let mut s = plate_in_a_tank(false);
let (base, _) = fieldcraft_engine::view::display(&s);
s.execute("results.animate", &json!({})).unwrap();
let view = s.results_view.as_ref().unwrap();
let result = s.latest_result(&view.step).unwrap().0;
assert!(view.animate && !view.deformed, "fluid animates its frames, not a deformation");
// The animation clock runs even though the fluid step does not deform.
assert_eq!(frame(view, result, 0.0), Some(0));
assert_eq!(frame(view, result, PERIOD * 0.5), Some(FRAMES / 2));
// The clock maps across the three frames: the plate_in_a_tank field, which starts at rest
// (the fixture sets no initial velocity, unlike the flow-past sample), then the settled flow.
let n = plot_at(&s, &base, None).unwrap().frame().unwrap().2;
assert_eq!(n, 3);
assert_eq!(plot_at(&s, &base, Some(0)).unwrap().pick.frame, Some(0));
assert_eq!(plot_at(&s, &base, Some(FRAMES - 1)).unwrap().pick.frame, Some(n - 1));
}
#[test] #[test]
fn heated_flow_fields_have_units_colours_and_face_markers() { fn heated_flow_fields_have_units_colours_and_face_markers() {
let mut s = plate_in_a_tank(true); let mut s = plate_in_a_tank(true);
@@ -492,7 +525,13 @@ pub(crate) mod tests {
assert!(p.contour.as_ref().unwrap().max.1 > 20.0); assert!(p.contour.as_ref().unwrap().max.1 > 20.0);
let last = p.frame().unwrap().2 - 1; let last = p.frame().unwrap().2 - 1;
s.execute("results.frame", &json!({"frame": 0})).unwrap(); s.execute("results.frame", &json!({"frame": 0})).unwrap();
assert_eq!(plot_at(&s, &base, None).unwrap().contour.as_ref().unwrap().max.1, 20.0, "the frame selector changes the temperature shown"); let at_rest = plot_at(&s, &base, None).unwrap();
let c = at_rest.contour.as_ref().unwrap();
let cells = &at_rest.flow.as_ref().unwrap().contour.as_ref().unwrap().values;
assert!(cells.iter().filter(|t| t.is_finite()).all(|t| *t == 20.0), "the frame selector changes the temperature shown");
// The faces are held at 60 °C from the start: the legend's maximum is a face, at a point on it.
assert_eq!(c.max.1, 60.0);
assert!(at_rest.extreme_position(c.max.0).is_some());
s.execute("results.frame", &json!({"frame": last})).unwrap(); s.execute("results.frame", &json!({"frame": last})).unwrap();
s.execute("results.surface", &json!({"field": "wall_heat_flux"})).unwrap(); s.execute("results.surface", &json!({"field": "wall_heat_flux"})).unwrap();
let p = plot_at(&s, &base, None).unwrap(); let p = plot_at(&s, &base, None).unwrap();
@@ -518,10 +557,14 @@ pub(crate) mod tests {
s.execute("results.animate", &json!({})).unwrap(); s.execute("results.animate", &json!({})).unwrap();
let base = DisplayMesh::from_model(&s.model); let base = DisplayMesh::from_model(&s.model);
let view = s.results_view.clone().unwrap(); let view = s.results_view.clone().unwrap();
assert_eq!(frame(&view, 0.0), Some(0)); let result = s.latest_result(&view.step).unwrap().0;
assert_eq!(frame(&view, PERIOD * 0.25), Some(FRAMES / 4)); assert_eq!(frame(&view, result, 0.0), Some(0));
assert_eq!(frame(&view, PERIOD * 1.25), Some(FRAMES / 4)); assert_eq!(frame(&view, result, PERIOD * 0.25), Some(FRAMES / 4));
let (_, result, _) = shown(&s).unwrap(); assert_eq!(frame(&view, result, PERIOD * 1.25), Some(FRAMES / 4));
// Animate on but Deformed off, and not a fluid step: nothing to animate, so it stays still.
let mut still = view.clone();
still.deformed = false;
assert_eq!(frame(&still, result, PERIOD * 0.25), None);
assert!((swing(result, Some(FRAMES / 4)) - 1.0).abs() < 1e-12); assert!((swing(result, Some(FRAMES / 4)) - 1.0).abs() < 1e-12);
assert!((swing(result, Some(3 * FRAMES / 4)) + 1.0).abs() < 1e-12); assert!((swing(result, Some(3 * FRAMES / 4)) + 1.0).abs() < 1e-12);
// At frame 0 a mode is undeformed; a quarter cycle later fully deformed. // At frame 0 a mode is undeformed; a quarter cycle later fully deformed.
+4
View File
@@ -364,6 +364,10 @@ pub fn controls(app: &mut ModelApp, plot: &Plot, ui: &mut egui::Ui, t: &Tokens)
if ui.add(egui::Slider::new(&mut frame, 0..=n.saturating_sub(1))).changed() { if ui.add(egui::Slider::new(&mut frame, 0..=n.saturating_sub(1))).changed() {
run.push(("results.frame", json!({"frame": frame}))); run.push(("results.frame", json!({"frame": frame})));
} }
let mut animate = plot.view.animate;
if ui.checkbox(&mut animate, "Animate").on_hover_text("Play the frames from the start-up transient to the settled flow").changed() {
run.push(("results.animate", json!({"on": animate})));
}
}); });
ui.label(RichText::new(format!("t = {}", with_unit(number(at), Quantity::Time.unit(plot.units).as_deref()))).color(t.text_dim)); ui.label(RichText::new(format!("t = {}", with_unit(number(at), Quantity::Time.unit(plot.units).as_deref()))).color(t.text_dim));
} else if let Some((k, factor, n)) = plot.frame().filter(|_| matches!(plot.result.kind(), Kind::Static(_))) { } else if let Some((k, factor, n)) = plot.frame().filter(|_| matches!(plot.result.kind(), Kind::Static(_))) {
+88 -1
View File
@@ -52,7 +52,7 @@ contact (#161) the ring will be pressed by contact against a groove.
Air (ρ = 1.2 kg/m³, μ = 1.8 × 10⁻⁵ Pa s) at 25 mm/s past the same L-bracket standing on the Air (ρ = 1.2 kg/m³, μ = 1.8 × 10⁻⁵ Pa s) at 25 mm/s past the same L-bracket standing on the
floor, the upright's back to the wind. A 240 × 120 × 100 mm box of 2.5 mm cells (184 320), floor, the upright's back to the wind. A 240 × 120 × 100 mm box of 2.5 mm cells (184 320),
uniform inlet, outlet at zero pressure, a no-slip floor and slip walls at the sides and top; uniform inlet, outlet at zero pressure, a no-slip floor and slip walls at the sides and top;
D3Q19 lattice Boltzmann (TRT) for 16 s, about 6.7 flow-throughs, ten frames. Reynolds number D3Q19 lattice Boltzmann (TRT) for 16 s, about 1.7 passes through the 240 mm box, ten frames. Reynolds number
83 on the upright's 50 mm height: laminar, a steady wake. About 10 s. 83 on the upright's 50 mm height: laminar, a steady wake. About 10 s.
**Look at:** the step's report (`solve.fluid`, or Results after Solve): the force on every **Look at:** the step's report (`solve.fluid`, or Results after Solve): the force on every
@@ -72,6 +72,14 @@ velocity magnitude on a slice through the middle of the box (`results.slice`; pr
`field: "pressure"`), the bracket coloured by the flow beside it (`results.surface`) and `field: "pressure"`), the bracket coloured by the flow beside it (`results.surface`) and
streamlines seeded upstream of it, coloured by speed (`results.streamlines`). streamlines seeded upstream of it, coloured by speed (`results.streamlines`).
![The solved flow: velocity on the mid-slice, a slow wake behind the upright and faster flow over it, with streamlines bending around the bracket](media/examples/flow-past-flow.png)
**Watch it start up:** a fluid step animates its frames (Results ▸ Animate, or `results.animate`),
playing the start-up transient from the uniform 25 mm/s initial field to the settled wake — the
wake forming behind the upright and the drag settling over the 16 s run. A clip is at
[flow-past-transient.mp4](media/examples/flow-past-transient.mp4), regenerated by
[tools/flow-clip](../tools/flow-clip/README.md).
## Heated bracket (`heated-bracket`) ## Heated bracket (`heated-bracket`)
![The steel bracket's temperature, 120 °C at the heated end of the upright falling to 95 °C at the foot](media/examples/heated-bracket.png) ![The steel bracket's temperature, 120 °C at the heated end of the upright falling to 95 °C at the foot](media/examples/heated-bracket.png)
@@ -219,3 +227,82 @@ temperature (with a small numerical tolerance); recorded hot walls give heat to
This coarse, short transient is a quick display demonstration, not a converged convection This coarse, short transient is a quick display demonstration, not a converged convection
benchmark. It has neither buoyancy nor solid heat conduction. The sample is isolated in benchmark. It has neither buoyancy nor solid heat conduction. The sample is isolated in
`crates/engine/src/sample/heated_flow.rs` and its gallery registration. `crates/engine/src/sample/heated_flow.rs` and its gallery registration.
# Electronics cooling studies
These are larger CFD studies, not one-click samples: each sets up a thermal lattice-Boltzmann
model from a committed SolidCraft recipe ([`examples/cooling`](../examples/cooling)) and solves it
on the CPU in a few minutes. `tools/cooling/make.py` (see its
[README](../tools/cooling/README.md)) rebuilds their numbers and pictures. They show what the
fluid solver can do for electronics cooling today, and where it stands in for what it cannot yet
do — approximations made honestly:
- **No conjugate heat transfer into solids yet**
([#157](https://github.com/tangency-labs/fieldcraft/issues/157)): a high-conductivity heat sink
or cold plate is modelled as an **isothermal surface**, and a component as a **held-temperature
or heat-flux face**. Real junction temperatures need conduction through the solid.
- **No fan curve**: a fan is a **uniform inlet velocity**. The current two-relaxation-time
collision is resolution-bound near its stability limit
([#557](https://github.com/tangency-labs/fieldcraft/issues/557)), so the forced cases use a
**modest duct velocity** standing in for a low-speed fan; higher fan speeds need the finer-grid
and turbulence work tracked there.
- **No surface-to-surface radiation**: the sealed-enclosure case is **convection only** (radiation
carries a real share of the heat in a sealed box).
Background for the planned electronics-cooling release is in
[discussion #584](https://github.com/tangency-labs/fieldcraft/discussions/584).
## Heat sink in forced air (`examples/cooling/heatsink`)
![Cool air entering a six-fin heat sink held at 80 °C, warming through the fins and carrying a hot wake downstream, streamlines coloured by air temperature](media/examples/cooling-heat-sink.png)
A 40 × 40 mm aluminium heat sink with six 18 mm fins, held isothermal at 80 °C (a high-conductivity
sink with fin efficiency near 1 — see the note above), in a 120 × 44 × 30 mm duct. Air at 20 °C
enters at 100 mm/s (0.1 m/s, a gentle fan), the duct walls no-slip and the outlet at zero pressure.
D3Q19 TRT with the D3Q7 thermal lattice on 158,400 cells of 1 mm, solved to 4 s (16,000 steps at a
halved time step, so the weakly-compressible density error stays near 1 %). About four minutes on
one CPU.
**Numbers:** the sink sheds **4.4 W** to the air — a sink-to-inlet **thermal resistance of
13.5 K/W** over the 60 K rise. Halving the time step again moves the heat by under 3 %, so the
figure is converged; it is in the range expected for a small sink in gentle airflow.
**Look at:** the air warming on the mid-plane temperature slice and the streamlines coloured by
temperature (`results.slice` / `results.streamlines` with `field: "temperature"`), with the sink
coloured by the flow beside it (`results.surface`). The heat each fin face gives the air is in the
`solve.fluid` report (`heat`, negative into the fluid).
**Caveat:** the reported minimum air temperature dips a few kelvin below the inlet near the sharp
fin corners — a local artifact of the D3Q7 anti-bounce-back wall at convex edges, not a physical
undershoot; the legend is clamped to the physical range.
**Regenerate:** `python3 tools/cooling/make.py heat-sink` (needs a display, or `xvfb-run`).
## Sealed enclosure with a PCB (`examples/cooling/enclosure`)
![A chip on a vertical PCB in a sealed box, a warm buoyant plume rising to a hot ceiling over cool air below, with the recirculation drawn by streamlines](media/examples/cooling-enclosure.png)
A small package held at 40 °C on a vertical PCB inside a sealed 60 × 40 × 60 mm air box whose walls
sit at 25 °C ambient — no fan. Buoyancy (Boussinesq, β = 3.4 × 10⁻³ K⁻¹, g along −z) drives a plume
up off the chip; the air stratifies warm at the ceiling over cool air below, and recirculates down
the walls. D3Q19 TRT with the D3Q7 thermal lattice and buoyancy on 144,000 cells of 1 mm, stepped
at a fixed 0.2 ms to 3 s (15,000 steps). About four minutes on one CPU.
**Numbers:** the chip sheds **63 mW** by natural convection — a natural-convection coefficient of
about **5 W/m²K** over its surface, which is in the textbook range for a small body in still air,
and a chip-to-ambient resistance of about **237 K/W**. That the bare chip's resistance is ~18×
the finned, fan-cooled sink's (13.5 K/W) is the point: natural convection alone carries little, so
real parts need a heat sink or airflow. The air stays between the wall and chip temperatures
(24.9–39.5 °C) with no numerical undershoot.
**Look at:** the temperature on the vertical mid-plane slice (the stratification), the streamlines
of the buoyant recirculation, and the heat the chip sheds in the `solve.fluid` report.
**Caveats:** the buoyant **start-up** needs a small fixed time step — the solver's automatic step
over-accelerates the first instant and goes unstable (lattice speed 0.505 at step 38); the steady
plume itself is gentle (73 mm/s, cell Reynolds ≈ 5), so a steady or accelerated solve would avoid
resolving the transient (see the Gaps note on the pull request). The rise is capped at 15 K for the
same reason. This is **convection only**: a sealed enclosure also loses a real share of the heat by
**surface-to-surface radiation**, which the solver does not model yet.
**Regenerate:** `python3 tools/cooling/make.py enclosure` (needs a display, or `xvfb-run`).
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.
+11 -1
View File
@@ -2,7 +2,7 @@
Generated by `cargo xtask oracle` from [`tests/oracle/`](../tests/oracle/); the policy is [verification.md](verification.md). Every case is our own model, built with FieldCraft commands from the problem's published description, and compared with the reference value its `expected.json` cites, within the stated tolerance and for the stated reason. Accepted results are frozen in each case's `fixture.json`: a later change that moves one beyond its band (10⁻⁹ of its size) fails CI even while the reference still passes. No reference value was produced with a commercial product. Generated by `cargo xtask oracle` from [`tests/oracle/`](../tests/oracle/); the policy is [verification.md](verification.md). Every case is our own model, built with FieldCraft commands from the problem's published description, and compared with the reference value its `expected.json` cites, within the stated tolerance and for the stated reason. Accepted results are frozen in each case's `fixture.json`: a later change that moves one beyond its band (10⁻⁹ of its size) fails CI even while the reference still passes. No reference value was produced with a commercial product.
**277 of 283 quantities pass** (6 known failure(s)) in 73 active cases: 62 theory, 11 published benchmark, 0 experiment; 8 pending cases wait for capabilities still being built. **282 of 289 quantities pass** (7 known failure(s)) in 77 active cases: 62 theory, 15 published benchmark, 0 experiment; 8 pending cases wait for capabilities still being built.
## Theory: closed-form solutions, patch tests and mesh invariants ## Theory: closed-form solutions, patch tests and mesh invariants
@@ -293,6 +293,12 @@ Generated by `cargo xtask oracle` from [`tests/oracle/`](../tests/oracle/); the
| [nafems-le1](../tests/oracle/nafems-le1/) | σ_yy at D (2, 0) | 92.7 | 92.869 | 0.182% | 1% | pass | matches | | [nafems-le1](../tests/oracle/nafems-le1/) | σ_yy at D (2, 0) | 92.7 | 92.869 | 0.182% | 1% | pass | matches |
| [nafems-le1-plane-stress](../tests/oracle/nafems-le1-plane-stress/) | σ_yy at D (2, 0) | 92.7 | 92.648 | -0.0559% | 1% | pass | matches | | [nafems-le1-plane-stress](../tests/oracle/nafems-le1-plane-stress/) | σ_yy at D (2, 0) | 92.7 | 92.648 | -0.0559% | 1% | pass | matches |
| [nafems-le10](../tests/oracle/nafems-le10/) | σ_yy at D (2, 0, 0.3) on the loaded face | -5.38 | -5.3339 | 0.857% | 2% | pass | matches | | [nafems-le10](../tests/oracle/nafems-le10/) | σ_yy at D (2, 0, 0.3) on the loaded face | -5.38 | -5.3339 | 0.857% | 2% | pass | matches |
| [nafems-le2](../tests/oracle/nafems-le2/) | tangential stress at E, outer surface, load case 1 (edge moment) | 60 | 59.936 | -0.107% | 1% | pass | matches |
| [nafems-le2](../tests/oracle/nafems-le2/) | tangential stress at E, outer surface, load case 2 (pressure and edge traction) | 60 | 59.904 | -0.161% | 1% | pass | matches |
| [nafems-le3](../tests/oracle/nafems-le3/) | radial displacement at A (10, 0, 0), m | 0.185 | 0.18418 | -0.446% | 2% | pass | matches |
| [nafems-le5](../tests/oracle/nafems-le5/) | σ_xx at A (2.5, −1, −1), mid-surface of the lower flange at its free edge | -108 | -109.45 | -1.35% | 2% | pass | matches |
| [nafems-le7](../tests/oracle/nafems-le7/) | axial stress σ_yy at D (1.0125, 1.4034), outer surface | 25.86 | 25.283 | -2.23% | 1% | known failure: the target is the axisymmetric thin-shell solution and FieldCraft has no axisymmetric shell element; our axisymmetric solid model converges to 25.27 (−2.3%), the value ESRD reports for its converged axisymmetric solid (quantity syy_D_solid) | — |
| [nafems-le7](../tests/oracle/nafems-le7/) | axial stress σ_yy at D (1.0125, 1.4034), outer surface, against the converged solid model | 25.27 | 25.283 | 0.0496% | 1% | pass | matches |
| [pinched-cylinder](../tests/oracle/pinched-cylinder/) | radial displacement under the load, mid-surface | -1.8248e-5 | -1.8038e-5 | 1.15% | 2% | pass | matches | | [pinched-cylinder](../tests/oracle/pinched-cylinder/) | radial displacement under the load, mid-surface | -1.8248e-5 | -1.8038e-5 | 1.15% | 2% | pass | matches |
| [scordelis-lo-roof-shell](../tests/oracle/scordelis-lo-roof-shell/) | vertical displacement at the midpoint of the free edge, mid-surface | -0.3024 | -0.29901 | 1.12% | 2% | pass | matches | | [scordelis-lo-roof-shell](../tests/oracle/scordelis-lo-roof-shell/) | vertical displacement at the midpoint of the free edge, mid-surface | -0.3024 | -0.29901 | 1.12% | 2% | pass | matches |
| [scordelis-lo-roof-solid](../tests/oracle/scordelis-lo-roof-solid/) | vertical displacement at the midpoint of the free edge, mid-surface | -0.3024 | -0.3014 | 0.33% | 2% | pass | matches | | [scordelis-lo-roof-solid](../tests/oracle/scordelis-lo-roof-solid/) | vertical displacement at the midpoint of the free edge, mid-surface | -0.3024 | -0.3014 | 0.33% | 2% | pass | matches |
@@ -335,6 +341,10 @@ Generated by `cargo xtask oracle` from [`tests/oracle/`](../tests/oracle/); the
- **[nafems-le1](../tests/oracle/nafems-le1/)** (benchmark): NAFEMS LE1 elliptic membrane (hex20 plane stress layer). Source: NAFEMS, The Standard NAFEMS Benchmarks, TNSB Rev. 3, October 1990, test LE1 (target σ_yy = 92.7 MPa at D); as reproduced in the Abaqus 2017 Benchmarks Guide, 'LE1: Plane stress elements—elliptic membrane'. Tolerances — syy_D: the target is published to three figures; plane stress modelled by a thin solid layer and the stress extrapolated to the edge add a few tenths of a percent - **[nafems-le1](../tests/oracle/nafems-le1/)** (benchmark): NAFEMS LE1 elliptic membrane (hex20 plane stress layer). Source: NAFEMS, The Standard NAFEMS Benchmarks, TNSB Rev. 3, October 1990, test LE1 (target σ_yy = 92.7 MPa at D); as reproduced in the Abaqus 2017 Benchmarks Guide, 'LE1: Plane stress elements—elliptic membrane'. Tolerances — syy_D: the target is published to three figures; plane stress modelled by a thin solid layer and the stress extrapolated to the edge add a few tenths of a percent
- **[nafems-le1-plane-stress](../tests/oracle/nafems-le1-plane-stress/)** (benchmark): NAFEMS LE1 elliptic membrane with 2D plane stress elements (quad8). Source: NAFEMS, The Standard NAFEMS Benchmarks, TNSB Rev. 3, October 1990, test LE1 (target σ_yy = 92.7 MPa at D); as reproduced in the Abaqus 2017 Benchmarks Guide, 'LE1: Plane stress elements—elliptic membrane'. Tolerances — syy_D: the target is published to three figures; the stress extrapolated to the edge on a converged mesh adds a few tenths of a percent - **[nafems-le1-plane-stress](../tests/oracle/nafems-le1-plane-stress/)** (benchmark): NAFEMS LE1 elliptic membrane with 2D plane stress elements (quad8). Source: NAFEMS, The Standard NAFEMS Benchmarks, TNSB Rev. 3, October 1990, test LE1 (target σ_yy = 92.7 MPa at D); as reproduced in the Abaqus 2017 Benchmarks Guide, 'LE1: Plane stress elements—elliptic membrane'. Tolerances — syy_D: the target is published to three figures; the stress extrapolated to the edge on a converged mesh adds a few tenths of a percent
- **[nafems-le10](../tests/oracle/nafems-le10/)** (benchmark): NAFEMS LE10 thick plate under pressure (hex20). Source: NAFEMS, The Standard NAFEMS Benchmarks, TNSB Rev. 3, October 1990, test LE10 (target σ_yy = −5.38 MPa at D); as reproduced in the Abaqus 2017 Benchmarks Guide, 'LE10: Thick plate under pressure'. Tolerances — syy_D: the target is published to three figures; at D the stress is extrapolated to a corner where the hole edge meets the loaded face, and published converged solutions scatter by 1–2% (Abaqus Benchmarks Guide fine meshes: −5.51 to −5.89) - **[nafems-le10](../tests/oracle/nafems-le10/)** (benchmark): NAFEMS LE10 thick plate under pressure (hex20). Source: NAFEMS, The Standard NAFEMS Benchmarks, TNSB Rev. 3, October 1990, test LE10 (target σ_yy = −5.38 MPa at D); as reproduced in the Abaqus 2017 Benchmarks Guide, 'LE10: Thick plate under pressure'. Tolerances — syy_D: the target is published to three figures; at D the stress is extrapolated to a corner where the hole edge meets the loaded face, and published converged solutions scatter by 1–2% (Abaqus Benchmarks Guide fine meshes: −5.51 to −5.89)
- **[nafems-le2](../tests/oracle/nafems-le2/)** (benchmark): NAFEMS LE2 cylindrical shell bending patch test (MITC4 shells). Source: NAFEMS, The Standard NAFEMS Benchmarks, TNSB Rev. 3, October 1990, test LE2 (target: tangential stress 60 MPa on the outer surface at E, both load cases); the problem and target as reproduced in the Abaqus 2016 Benchmarks Guide, section 4.2.2 'LE2: Cylindrical shell bending patch test', and in the ESRD StressCheck Benchmarks Guide 'The Standard NAFEMS Benchmarks: Linear Elastic Tests' (2018), p. 8. Tolerances — stt_E_moment: the target is the exact thin-shell value (6M/t²) and the stress is uniform over the patch; the 8 × 8 flat facets tilt the element axes by ±1.875° from the hoop direction, which moves 0.1% of it into σ_zz, and 16 × 16 is within 0.03% (see README); stt_E_pressure: the target is the exact membrane value (pR/t) and the stress is uniform over the patch; as in load case 1 the facets' tilt and the pressure on flat facets keep 8 × 8 within 0.2%, 16 × 16 within 0.05% (see README)
- **[nafems-le3](../tests/oracle/nafems-le3/)** (benchmark): NAFEMS LE3 hemispherical shell with point loads (MITC4 shells). Source: NAFEMS, The Standard NAFEMS Benchmarks, TNSB Rev. 3, October 1990, test LE3 (target: radial displacement 0.185 m at A); the problem and target as reproduced in the Abaqus 2016 Benchmarks Guide, section 4.2.3 'LE3: Hemispherical shell with point loads', and in the ESRD StressCheck Benchmarks Guide 'The Standard NAFEMS Benchmarks: Linear Elastic Tests' (2018), p. 12. Tolerances — ur_A: the target is published to three figures; the displacement under a point load on a shear-deformable shell keeps growing slowly as the mesh is refined (32 × 32 is 0.44% low, 64 × 64 0.16%; see README)
- **[nafems-le5](../tests/oracle/nafems-le5/)** (benchmark): NAFEMS LE5 Z-section cantilever (hex20). Source: NAFEMS, The Standard NAFEMS Benchmarks, TNSB Rev. 3, October 1990, test LE5 (target: axial stress −108 MPa at A, mid-surface); the problem and target as reproduced in the Abaqus 2016 Benchmarks Guide, section 4.2.5 'LE5: Z-section cantilever', and in the ESRD StressCheck Benchmarks Guide 'The Standard NAFEMS Benchmarks: Linear Elastic Tests' (2018), p. 15. Tolerances — sxx_A: the target is published to three figures and comes from a thin-walled idealisation; a 3D solid model of the section converges about 1.5% beyond it (ours: −109.46 here, −109.60 on the next mesh; ESRD's p-version solid: −109.5, see README)
- **[nafems-le7](../tests/oracle/nafems-le7/)** (benchmark): NAFEMS LE7 axisymmetric cylinder/sphere under pressure (axisymmetric quad8). Source: NAFEMS, The Standard NAFEMS Benchmarks, TNSB Rev. 3, October 1990, test LE7 (target: axial stress 25.86 MPa on the outer surface at D, R = 1.0125, Z = 1.4034), as reproduced in the ESRD StressCheck Benchmarks Guide 'The Standard NAFEMS Benchmarks: Linear Elastic Tests' (2018), p. 23, which also gives its converged axisymmetric solid result, 25.27 MPa (p. 24); the Abaqus 2016 Benchmarks Guide, section 4.2.7 'LE7: Axisymmetric cylinder/sphere under pressure', gives the target rounded to 25.9 MPa. Tolerances — syy_D: the target is published to four figures for the thin-shell model of the vessel, and a model of the same theory converges well within 1%; syy_D_solid: the same model idealisation (axisymmetric solid) as the reference, which ESRD reports converged to under 1% on two meshes; ours moves by 0.05% on the next refinement (see README)
- **[nl-contact-patch](../tests/oracle/nl-contact-patch/)** (theory): Contact patch test: two blocks with non-matching meshes in uniaxial compression (implicit mortar contact). Source: The contact patch test: a contact discretisation must transmit a uniform pressure across a non-matching interface exactly; Taylor & Papadopoulos, On a patch test for contact problems in two dimensions, Nonlinear Computational Mechanics, Springer 1991, 690–702; Crisfield, Re-visiting the contact patch test, IJNME 48:435–449, 2000; passed by mortar methods: Puso & Laursen, CMAME 193:601–629, 2004. Uniaxial stress between frictionless symmetry planes is uniform in both blocks. Tolerances — small_lower_corner: small sliding is linear and its mortar integrals exact on these rectangular faces: a uniform stress is the discrete solution up to the convergence tolerance (10⁻¹⁰); small_lower_centre: as for the corner element; small_lower_base: as for the corner element; small_upper_top: as for the corner element; small_lateral: frictionless symmetry planes and a frictionless interface leave the blocks free to expand sideways; the non-matching interface must not hold them; small_pressure: the mortar multipliers are the nodal values of the contact pressure, uniform and equal to the stress across the interface; small_force: the pressure carries the whole load across the interface; finite_lower_corner: finite sliding is exact up to the geometric terms of the gaps the augmentation leaves (10⁻⁵ of the element thickness) and the default convergence tolerances; finite_lower_centre: as for the corner element; finite_lower_base: as for the corner element; finite_upper_top: as for the corner element; finite_lateral: frictionless symmetry planes and a frictionless interface leave the blocks free to expand sideways; the non-matching interface must not hold them; finite_pressure: the mortar multipliers are the nodal values of the contact pressure, uniform and equal to the stress across the interface; finite_force: the pressure carries the whole load across the interface; small_stress: the squeeze less the penetration the augmented Lagrangian leaves (up to 10⁻⁵ of the element thickness, a few 10⁻⁵ of the squeeze) - **[nl-contact-patch](../tests/oracle/nl-contact-patch/)** (theory): Contact patch test: two blocks with non-matching meshes in uniaxial compression (implicit mortar contact). Source: The contact patch test: a contact discretisation must transmit a uniform pressure across a non-matching interface exactly; Taylor & Papadopoulos, On a patch test for contact problems in two dimensions, Nonlinear Computational Mechanics, Springer 1991, 690–702; Crisfield, Re-visiting the contact patch test, IJNME 48:435–449, 2000; passed by mortar methods: Puso & Laursen, CMAME 193:601–629, 2004. Uniaxial stress between frictionless symmetry planes is uniform in both blocks. Tolerances — small_lower_corner: small sliding is linear and its mortar integrals exact on these rectangular faces: a uniform stress is the discrete solution up to the convergence tolerance (10⁻¹⁰); small_lower_centre: as for the corner element; small_lower_base: as for the corner element; small_upper_top: as for the corner element; small_lateral: frictionless symmetry planes and a frictionless interface leave the blocks free to expand sideways; the non-matching interface must not hold them; small_pressure: the mortar multipliers are the nodal values of the contact pressure, uniform and equal to the stress across the interface; small_force: the pressure carries the whole load across the interface; finite_lower_corner: finite sliding is exact up to the geometric terms of the gaps the augmentation leaves (10⁻⁵ of the element thickness) and the default convergence tolerances; finite_lower_centre: as for the corner element; finite_lower_base: as for the corner element; finite_upper_top: as for the corner element; finite_lateral: frictionless symmetry planes and a frictionless interface leave the blocks free to expand sideways; the non-matching interface must not hold them; finite_pressure: the mortar multipliers are the nodal values of the contact pressure, uniform and equal to the stress across the interface; finite_force: the pressure carries the whole load across the interface; small_stress: the squeeze less the penetration the augmented Lagrangian leaves (up to 10⁻⁵ of the element thickness, a few 10⁻⁵ of the squeeze)
- **[nl-contact-patch-axisymmetric](../tests/oracle/nl-contact-patch-axisymmetric/)** (theory): Contact patch test, axisymmetric: two rings with non-matching edges in axial compression (implicit mortar contact). Source: The contact patch test: a contact discretisation must transmit a uniform pressure across a non-matching interface exactly; Taylor & Papadopoulos, On a patch test for contact problems in two dimensions, Nonlinear Computational Mechanics, Springer 1991, 690–702; passed by 2D mortar methods: Yang, Laursen & Meng, IJNME 62:1183–1225, 2005. Uniaxial stress between frictionless supports is uniform in both blocks. Tolerances — small_lower_corner: small sliding is linear and its edge mortar integrals exact (degree-3 Gauss on the overlaps): the uniform stress is the discrete solution up to the convergence tolerance; small_lower_far: as for the corner element; small_lower_base: as for the corner element; small_upper_top: as for the corner element; small_lateral: a frictionless interface (and symmetry plane) leaves the blocks free to expand sideways; the non-matching interface must not hold them; small_pressure: the mortar multipliers are the nodal values of the contact pressure, uniform and equal to the stress across the interface; small_force: the pressure carries the whole load across the interface (both are totals over the circumference); finite_lower_corner: finite sliding is exact up to the geometric terms of the gaps the augmentation leaves (10⁻⁵ of the element thickness) and the default convergence tolerances; finite_lower_far: as for the corner element; finite_lower_base: as for the corner element; finite_upper_top: as for the corner element; finite_lateral: a frictionless interface (and symmetry plane) leaves the blocks free to expand sideways; the non-matching interface must not hold them; finite_pressure: the mortar multipliers are the nodal values of the contact pressure, uniform and equal to the stress across the interface; finite_force: the pressure carries the whole load across the interface (both are totals over the circumference); small_stress: the squeeze less the penetration the augmented Lagrangian leaves (up to 10⁻⁵ of the element thickness) - **[nl-contact-patch-axisymmetric](../tests/oracle/nl-contact-patch-axisymmetric/)** (theory): Contact patch test, axisymmetric: two rings with non-matching edges in axial compression (implicit mortar contact). Source: The contact patch test: a contact discretisation must transmit a uniform pressure across a non-matching interface exactly; Taylor & Papadopoulos, On a patch test for contact problems in two dimensions, Nonlinear Computational Mechanics, Springer 1991, 690–702; passed by 2D mortar methods: Yang, Laursen & Meng, IJNME 62:1183–1225, 2005. Uniaxial stress between frictionless supports is uniform in both blocks. Tolerances — small_lower_corner: small sliding is linear and its edge mortar integrals exact (degree-3 Gauss on the overlaps): the uniform stress is the discrete solution up to the convergence tolerance; small_lower_far: as for the corner element; small_lower_base: as for the corner element; small_upper_top: as for the corner element; small_lateral: a frictionless interface (and symmetry plane) leaves the blocks free to expand sideways; the non-matching interface must not hold them; small_pressure: the mortar multipliers are the nodal values of the contact pressure, uniform and equal to the stress across the interface; small_force: the pressure carries the whole load across the interface (both are totals over the circumference); finite_lower_corner: finite sliding is exact up to the geometric terms of the gaps the augmentation leaves (10⁻⁵ of the element thickness) and the default convergence tolerances; finite_lower_far: as for the corner element; finite_lower_base: as for the corner element; finite_upper_top: as for the corner element; finite_lateral: a frictionless interface (and symmetry plane) leaves the blocks free to expand sideways; the non-matching interface must not hold them; finite_pressure: the mortar multipliers are the nodal values of the contact pressure, uniform and equal to the stress across the interface; finite_force: the pressure carries the whole load across the interface (both are totals over the circumference); small_stress: the squeeze less the penetration the augmented Lagrangian leaves (up to 10⁻⁵ of the element thickness)
- **[nl-contact-patch-plane-strain](../tests/oracle/nl-contact-patch-plane-strain/)** (theory): Contact patch test in plane strain: two blocks with non-matching edges in uniaxial compression (implicit mortar contact). Source: The contact patch test: a contact discretisation must transmit a uniform pressure across a non-matching interface exactly; Taylor & Papadopoulos, On a patch test for contact problems in two dimensions, Nonlinear Computational Mechanics, Springer 1991, 690–702; passed by 2D mortar methods: Yang, Laursen & Meng, IJNME 62:1183–1225, 2005. Uniaxial stress between frictionless supports is uniform in both blocks. Tolerances — small_lower_corner: small sliding is linear and its edge mortar integrals exact (degree-3 Gauss on the overlaps): the uniform stress is the discrete solution up to the convergence tolerance; small_lower_far: as for the corner element; small_lower_base: as for the corner element; small_upper_top: as for the corner element; small_lateral: a frictionless interface (and symmetry plane) leaves the blocks free to expand sideways; the non-matching interface must not hold them; small_pressure: the mortar multipliers are the nodal values of the contact pressure, uniform and equal to the stress across the interface; small_force: the pressure carries the whole load across the interface (both are totals over the thickness); finite_lower_corner: finite sliding is exact up to the geometric terms of the gaps the augmentation leaves (10⁻⁵ of the element thickness) and the default convergence tolerances; finite_lower_far: as for the corner element; finite_lower_base: as for the corner element; finite_upper_top: as for the corner element; finite_lateral: a frictionless interface (and symmetry plane) leaves the blocks free to expand sideways; the non-matching interface must not hold them; finite_pressure: the mortar multipliers are the nodal values of the contact pressure, uniform and equal to the stress across the interface; finite_force: the pressure carries the whole load across the interface (both are totals over the thickness); small_stress: the squeeze less the penetration the augmented Lagrangian leaves (up to 10⁻⁵ of the element thickness) - **[nl-contact-patch-plane-strain](../tests/oracle/nl-contact-patch-plane-strain/)** (theory): Contact patch test in plane strain: two blocks with non-matching edges in uniaxial compression (implicit mortar contact). Source: The contact patch test: a contact discretisation must transmit a uniform pressure across a non-matching interface exactly; Taylor & Papadopoulos, On a patch test for contact problems in two dimensions, Nonlinear Computational Mechanics, Springer 1991, 690–702; passed by 2D mortar methods: Yang, Laursen & Meng, IJNME 62:1183–1225, 2005. Uniaxial stress between frictionless supports is uniform in both blocks. Tolerances — small_lower_corner: small sliding is linear and its edge mortar integrals exact (degree-3 Gauss on the overlaps): the uniform stress is the discrete solution up to the convergence tolerance; small_lower_far: as for the corner element; small_lower_base: as for the corner element; small_upper_top: as for the corner element; small_lateral: a frictionless interface (and symmetry plane) leaves the blocks free to expand sideways; the non-matching interface must not hold them; small_pressure: the mortar multipliers are the nodal values of the contact pressure, uniform and equal to the stress across the interface; small_force: the pressure carries the whole load across the interface (both are totals over the thickness); finite_lower_corner: finite sliding is exact up to the geometric terms of the gaps the augmentation leaves (10⁻⁵ of the element thickness) and the default convergence tolerances; finite_lower_far: as for the corner element; finite_lower_base: as for the corner element; finite_upper_top: as for the corner element; finite_lateral: a frictionless interface (and symmetry plane) leaves the blocks free to expand sideways; the non-matching interface must not hold them; finite_pressure: the mortar multipliers are the nodal values of the contact pressure, uniform and equal to the stress across the interface; finite_force: the pressure carries the whole load across the interface (both are totals over the thickness); small_stress: the squeeze less the penetration the augmented Lagrangian leaves (up to 10⁻⁵ of the element thickness)
@@ -0,0 +1,32 @@
{
"commands": [
{
"command": "solid.box",
"params": {
"corner": [
10,
19,
6
],
"length": 40,
"width": 2,
"height": 44,
"body_name": "PCB"
}
},
{
"command": "solid.box",
"params": {
"corner": [
27,
21,
26
],
"length": 16,
"width": 6,
"height": 12,
"body_name": "Chip"
}
}
]
}
+120
View File
@@ -0,0 +1,120 @@
{
"commands": [
{
"command": "solid.box",
"params": {
"corner": [
0,
0,
0
],
"length": 40,
"width": 40,
"height": 3,
"body_name": "HeatSink"
}
},
{
"command": "solid.box",
"params": {
"corner": [
0,
4.25,
3
],
"length": 40,
"width": 1.5,
"height": 18,
"operation": "join",
"targets": [
"HeatSink"
]
}
},
{
"command": "solid.box",
"params": {
"corner": [
0,
10.25,
3
],
"length": 40,
"width": 1.5,
"height": 18,
"operation": "join",
"targets": [
"HeatSink"
]
}
},
{
"command": "solid.box",
"params": {
"corner": [
0,
16.25,
3
],
"length": 40,
"width": 1.5,
"height": 18,
"operation": "join",
"targets": [
"HeatSink"
]
}
},
{
"command": "solid.box",
"params": {
"corner": [
0,
22.25,
3
],
"length": 40,
"width": 1.5,
"height": 18,
"operation": "join",
"targets": [
"HeatSink"
]
}
},
{
"command": "solid.box",
"params": {
"corner": [
0,
28.25,
3
],
"length": 40,
"width": 1.5,
"height": 18,
"operation": "join",
"targets": [
"HeatSink"
]
}
},
{
"command": "solid.box",
"params": {
"corner": [
0,
34.25,
3
],
"length": 40,
"width": 1.5,
"height": 18,
"operation": "join",
"targets": [
"HeatSink"
]
}
}
]
}
+414
View File
@@ -0,0 +1,414 @@
// The /claim bot's logic, used by .github/workflows/claim.yml through actions/github-script.
// Everything takes the github-script `github` client and `context`, so the tests can pass fakes.
'use strict';
// CONTRIBUTING.md, "Before you start: claim an issue": a claimed issue is assigned and labelled
// `claimed`; `in progress` marks the core team's own work and can't be claimed; contributors pick
// issues labelled `help wanted` or `good first issue`; no visible progress for 14 days frees it.
const LABEL = 'claimed';
const LABEL_COLOR = '0052cc';
const LABEL_DESCRIPTION = 'Assigned to a contributor; see CONTRIBUTING.md';
const CORE = 'in progress';
const OPEN = ['help wanted', 'good first issue'];
const CLAIM_DAYS = 14;
const DAY = 24 * 3600 * 1000;
function parseCommand(body) {
const text = String(body || '').trimStart();
if (/^\/claim(\s|$)/.test(text)) return 'claim';
if (/^\/unclaim(\s|$)/.test(text)) return 'unclaim';
return null;
}
function fmt(date) {
return new Date(date).toISOString().slice(0, 16).replace('T', ' ') + ' UTC';
}
function repoArgs(context) {
return { owner: context.repo.owner, repo: context.repo.repo };
}
async function isMaintainer(github, context, login, cache) {
if (login === context.repo.owner) return true;
if (cache && cache.has(login)) return cache.get(login);
let result = false;
try {
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
...repoArgs(context),
username: login,
});
result =
['admin', 'write', 'maintain'].includes(data.permission) ||
['admin', 'maintain', 'write'].includes(data.role_name);
} catch (e) {
result = false;
}
if (cache) cache.set(login, result);
return result;
}
async function ensureLabel(github, context) {
try {
await github.rest.issues.getLabel({ ...repoArgs(context), name: LABEL });
} catch (e) {
if (e.status !== 404) throw e;
try {
await github.rest.issues.createLabel({
...repoArgs(context),
name: LABEL,
color: LABEL_COLOR,
description: LABEL_DESCRIPTION,
});
} catch (e2) {
if (e2.status !== 422) throw e2; // created meanwhile
}
}
}
// When the label was last added, from the timeline; the issue's update time if it can't be found.
async function labeledAt(github, context, issue, timeline) {
const events = timeline || (await getTimeline(github, context, issue.number));
let at = null;
for (const ev of events) {
if (ev.event === 'labeled' && ev.label && ev.label.name === LABEL) at = ev.created_at;
}
return new Date(at || issue.updated_at).getTime();
}
function getTimeline(github, context, number) {
return github.paginate(github.rest.issues.listEventsForTimeline, {
...repoArgs(context),
issue_number: number,
per_page: 100,
});
}
// Open pull requests that mention the issue, from the timeline's cross-references.
function linkedPulls(timeline) {
const seen = new Map();
for (const ev of timeline) {
const src = ev.event === 'cross-referenced' && ev.source && ev.source.issue;
if (src && src.pull_request && src.state === 'open') seen.set(src.number, src);
}
return [...seen.values()];
}
async function lastPush(github, context, pr) {
const { data } = await github.rest.pulls.get({ ...repoArgs(context), pull_number: pr.number });
const { data: commit } = await github.rest.repos.getCommit({
...repoArgs(context),
ref: data.head.sha,
});
return new Date(commit.commit.committer.date).getTime();
}
// Where a claimed issue stands: when it lapses, and whether it is free to release now.
async function claimStatus(github, context, issue, now) {
const timeline = await getTimeline(github, context, issue.number);
const since = await labeledAt(github, context, issue, timeline);
const pulls = linkedPulls(timeline);
if (pulls.length === 0) {
const expires = since + CLAIM_DAYS * DAY;
return { pulls, since, expires, release: now >= expires, reason: 'no PR' };
}
let newest = since;
for (const pr of pulls) newest = Math.max(newest, await lastPush(github, context, pr));
const expires = newest + CLAIM_DAYS * DAY;
return { pulls, since, expires, release: now >= expires, reason: 'idle PR' };
}
async function react(github, context, content) {
await github.rest.reactions.createForIssueComment({
...repoArgs(context),
comment_id: context.payload.comment.id,
content,
});
}
async function reply(github, context, body) {
await github.rest.issues.createComment({
...repoArgs(context),
issue_number: context.payload.issue.number,
body,
});
}
function labelNames(issue) {
return (issue.labels || []).map((l) => (typeof l === 'string' ? l : l.name));
}
function hasLabel(issue, name = LABEL) {
return labelNames(issue).includes(name);
}
// Open to contributors: labelled `help wanted` or `good first issue`, and not the core team's.
function claimable(issue) {
const names = labelNames(issue);
return !names.includes(CORE) && OPEN.some((n) => names.includes(n));
}
async function release(github, context, issue, assignees) {
if (assignees.length) {
await github.rest.issues.removeAssignees({
...repoArgs(context),
issue_number: issue.number,
assignees,
});
}
try {
await github.rest.issues.removeLabel({
...repoArgs(context),
issue_number: issue.number,
name: LABEL,
});
} catch (e) {
if (e.status !== 404) throw e;
}
}
async function claim(github, context, now) {
const issue = context.payload.issue;
const user = context.payload.comment.user.login;
const n = issue.number;
const cache = new Map();
const assignees = (issue.assignees || []).map((a) => a.login);
if (assignees.includes(user)) {
await react(github, context, '+1'); // already theirs
return;
}
if (hasLabel(issue, CORE)) {
await react(github, context, 'eyes');
await reply(github, context, `The core team is working on this one (\`${CORE}\`), so it can't be claimed.`);
return;
}
if (!claimable(issue) && !(await isMaintainer(github, context, user, cache))) {
await react(github, context, 'eyes');
await reply(
github,
context,
`Only issues labelled \`help wanted\` or \`good first issue\` can be claimed, @${user}. ` +
'Say here what you would like to do and a maintainer will open it up or assign it to you.',
);
return;
}
// Someone else is assigned: theirs unless the claim has lapsed (never for a maintainer).
let held = assignees.length > 0 || hasLabel(issue);
let status = null;
if (held) {
let maintained = false;
for (const a of assignees) maintained = maintained || (await isMaintainer(github, context, a, cache));
if (!maintained) {
status = await claimStatus(github, context, issue, now);
if (status.release) held = false;
}
if (held) {
await react(github, context, 'eyes');
const who = assignees.length ? assignees.map((a) => '@' + a).join(', ') + ' has' : 'Someone has';
const when = maintained || !status ? '' : ` The claim lapses on ${fmt(status.expires)} unless there's progress.`;
await reply(github, context, `${who} claimed this already.${when}`);
return;
}
} else {
// Not claimed, but someone's open PR may already cover it.
const timeline = await getTimeline(github, context, n);
const other = linkedPulls(timeline).find((p) => p.user && p.user.login !== user);
if (other) {
await react(github, context, 'eyes');
await reply(github, context, `#${other.number} already covers this one.`);
return;
}
}
// Free (or lapsed): hand it to the commenter. Clear a lapsed claimant first.
if (assignees.length) {
await github.rest.issues.removeAssignees({ ...repoArgs(context), issue_number: n, assignees });
}
await ensureLabel(github, context);
await github.rest.issues.addLabels({ ...repoArgs(context), issue_number: n, labels: [LABEL] });
try {
await github.rest.issues.addAssignees({
...repoArgs(context),
issue_number: n,
assignees: [user],
});
} catch (e) {
// Assignment can be refused; the label and the reply naming them still stand.
}
await react(github, context, '+1');
await reply(
github,
context,
`Claimed by @${user}. Please open a draft PR early with \`Closes #${n}\`; the claim lapses after ${CLAIM_DAYS} days without visible progress. \`/unclaim\` releases it.`,
);
}
async function unclaim(github, context) {
const issue = context.payload.issue;
const user = context.payload.comment.user.login;
const assignees = (issue.assignees || []).map((a) => a.login);
if (!hasLabel(issue) && assignees.length === 0) {
await react(github, context, '+1'); // nothing to release
return;
}
const allowed = assignees.includes(user) || (await isMaintainer(github, context, user));
if (!allowed) {
await react(github, context, 'eyes');
await reply(github, context, `Only the person who claimed this, or a maintainer, can release it, @${user}.`);
return;
}
await release(github, context, issue, assignees);
await react(github, context, '+1');
await reply(github, context, `Released. This one is free again, so anyone can \`/claim\` it.`);
}
// The comment event.
async function handleComment({ github, context, now = Date.now() }) {
const { issue, comment } = context.payload;
if (!issue || issue.pull_request || !comment) return 'skip';
if (issue.state !== 'open' || issue.locked) return 'skip';
if (comment.user.type === 'Bot' || comment.user.login.endsWith('[bot]')) return 'skip';
const cmd = parseCommand(comment.body);
if (!cmd) return 'skip';
if (cmd === 'claim') await claim(github, context, now);
else await unclaim(github, context);
return cmd;
}
// Issue numbers a PR claims: `#N` in its title (`<summary> (#N)`) and closing keywords in its body.
const MAX_REFS = 5;
function referencedIssues(title, body) {
const found = new Set();
for (const m of String(title || '').matchAll(/(?:^|[\s(])#(\d{1,7})\b/g)) found.add(Number(m[1]));
const closing = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s+#(\d{1,7})\b/gi;
for (const m of String(body || '').matchAll(closing)) found.add(Number(m[1]));
return [...found].filter((n) => n > 0).slice(0, MAX_REFS);
}
// A pull request was opened: it claims each open-for-contributors issue it references that nobody
// holds yet. The core team's PRs claim nothing (their issues carry `in progress` instead).
async function handlePull({ github, context }) {
const pull = context.payload.pull_request;
if (!pull || pull.state !== 'open') return [];
const author = pull.user && pull.user.login;
if (!author || pull.user.type === 'Bot' || author.endsWith('[bot]')) return [];
if (await isMaintainer(github, context, author)) return [];
const claimed = [];
for (const n of referencedIssues(pull.title, pull.body)) {
if (n === pull.number) continue;
let issue;
try {
({ data: issue } = await github.rest.issues.get({ ...repoArgs(context), issue_number: n }));
} catch (e) {
if (e.status === 404 || e.status === 410) continue;
throw e;
}
if (issue.pull_request || issue.state !== 'open' || issue.locked) continue;
const assignees = (issue.assignees || []).map((a) => a.login);
if (assignees.includes(author)) {
if (!hasLabel(issue)) {
await ensureLabel(github, context);
await github.rest.issues.addLabels({ ...repoArgs(context), issue_number: n, labels: [LABEL] });
}
continue;
}
if (hasLabel(issue, CORE)) {
await github.rest.issues.createComment({
...repoArgs(context),
issue_number: pull.number,
body: `#${n} is being worked on by the core team (\`${CORE}\`). Please ask on the issue before going further.`,
});
continue;
}
if (assignees.length || hasLabel(issue)) {
const who = assignees.length ? assignees.map((a) => '@' + a).join(', ') : 'someone else';
await github.rest.issues.createComment({
...repoArgs(context),
issue_number: pull.number,
body: `#${n} is already claimed by ${who}. If this PR duplicates their work, please close it or offer to help there.`,
});
continue;
}
// An older open PR for the issue wins (lowest number), even if it never recorded its claim.
const timeline = await getTimeline(github, context, n);
const older = linkedPulls(timeline).find((p) => p.number < pull.number && p.user && p.user.login !== author);
if (older) {
await github.rest.issues.createComment({
...repoArgs(context),
issue_number: pull.number,
body: `#${older.number} claimed #${n} first (the lowest-numbered open PR wins). Please close this one or offer to help there.`,
});
continue;
}
if (!claimable(issue)) {
await github.rest.issues.createComment({
...repoArgs(context),
issue_number: pull.number,
body: `#${n} isn't open for contributors yet (no \`help wanted\` or \`good first issue\` label), so this PR doesn't claim it. A maintainer will reply on the issue.`,
});
continue;
}
await ensureLabel(github, context);
await github.rest.issues.addLabels({ ...repoArgs(context), issue_number: n, labels: [LABEL] });
try {
await github.rest.issues.addAssignees({ ...repoArgs(context), issue_number: n, assignees: [author] });
} catch (e) {
// Assignment can be refused; the label and the comment naming them still stand.
}
await github.rest.issues.createComment({
...repoArgs(context),
issue_number: n,
body: `Claimed by @${author} through #${pull.number}. The claim lapses after ${CLAIM_DAYS} days without pushes.`,
});
claimed.push(n);
}
return claimed;
}
// The daily sweep over open issues labelled `claimed`.
async function sweep({ github, context, now = Date.now() }) {
const issues = await github.paginate(github.rest.issues.listForRepo, {
...repoArgs(context),
state: 'open',
labels: LABEL,
per_page: 100,
});
const cache = new Map();
const released = [];
for (const issue of issues) {
if (issue.pull_request || issue.locked || hasLabel(issue, CORE)) continue;
const assignees = (issue.assignees || []).map((a) => a.login);
let skip = false;
for (const a of assignees) skip = skip || (await isMaintainer(github, context, a, cache));
if (skip) continue;
const status = await claimStatus(github, context, issue, now);
if (!status.release) continue;
await release(github, context, issue, assignees);
await github.rest.issues.createComment({
...repoArgs(context),
issue_number: issue.number,
body:
status.reason === 'no PR'
? `No pull request showed up within ${CLAIM_DAYS} days, so this one is free again. Anyone can \`/claim\` it.`
: `Nothing has been pushed for ${CLAIM_DAYS} days, so this one is free again. Anyone can \`/claim\` it.`,
});
released.push(issue.number);
}
return released;
}
module.exports = {
LABEL,
CORE,
claimable,
parseCommand,
referencedIssues,
isMaintainer,
linkedPulls,
claimStatus,
handleComment,
handlePull,
sweep,
};
+331
View File
@@ -0,0 +1,331 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const claim = require('./claim.js');
const NOW = Date.parse('2026-10-16T12:00:00Z');
const H = 3600 * 1000;
const D = 24 * H;
const ago = (ms) => new Date(NOW - ms).toISOString();
// A fake github-script client that records writes. `world` describes the repo.
function fake(world = {}) {
const calls = [];
const rec = (name) => async (args) => {
calls.push([name, args]);
if (world.fail && world.fail[name]) throw Object.assign(new Error(name), { status: world.fail[name] });
return { data: {} };
};
const github = {
paginate: async (fn, args) => (await fn(args)).data,
rest: {
repos: {
getCollaboratorPermissionLevel: async ({ username }) => {
const permission = (world.perms || {})[username] || 'none';
return { data: { permission } };
},
getCommit: async () => ({ data: { commit: { committer: { date: world.pushed || ago(H) } } } }),
},
pulls: { get: async () => ({ data: { head: { sha: 'abc' } } }) },
reactions: { createForIssueComment: rec('react') },
issues: {
getLabel: async () => {
if (world.labelMissing) throw Object.assign(new Error('nf'), { status: 404 });
return { data: {} };
},
createLabel: rec('createLabel'),
addLabels: rec('addLabels'),
removeLabel: rec('removeLabel'),
addAssignees: rec('addAssignees'),
removeAssignees: rec('removeAssignees'),
createComment: rec('comment'),
listForRepo: async () => ({ data: world.issues || [] }),
get: async ({ issue_number }) => {
const found = (world.byNumber || {})[issue_number];
if (!found) throw Object.assign(new Error('nf'), { status: 404 });
return { data: found };
},
listEventsForTimeline: async () => ({ data: world.timeline || [] }),
},
},
};
return { github, calls };
}
const names = (calls) => calls.map((c) => c[0]);
const find = (calls, n) => calls.find((c) => c[0] === n);
const labeled = (at) => ({ event: 'labeled', label: { name: 'claimed' }, created_at: at });
const pr = (number, login = 'other', state = 'open') => ({
event: 'cross-referenced',
source: { issue: { number, state, pull_request: {}, user: { login } } },
});
function ctx(body, { assignees = [], labels = ['help wanted'], user = 'alice', extra = {} } = {}) {
return {
repo: { owner: 'tangency-labs', repo: 'fieldcraft' },
payload: {
issue: {
number: 7,
state: 'open',
locked: false,
updated_at: ago(1000 * H),
assignees: assignees.map((login) => ({ login })),
labels: labels.map((name) => ({ name })),
...extra,
},
comment: { id: 99, body, user: { login: user, type: 'User' } },
},
};
}
test('parseCommand', () => {
assert.equal(claim.parseCommand('/claim'), 'claim');
assert.equal(claim.parseCommand(' /claim please'), 'claim');
assert.equal(claim.parseCommand('/unclaim'), 'unclaim');
assert.equal(claim.parseCommand('/claimed'), null);
assert.equal(claim.parseCommand('I would /claim this'), null);
assert.equal(claim.parseCommand(undefined), null);
});
test('claim on a free issue assigns, labels, reacts and replies', async () => {
const { github, calls } = fake({ labelMissing: true });
assert.equal(await claim.handleComment({ github, context: ctx('/claim'), now: NOW }), 'claim');
assert.deepEqual(find(calls, 'addAssignees')[1].assignees, ['alice']);
assert.deepEqual(find(calls, 'addLabels')[1].labels, ['claimed']);
assert.equal(find(calls, 'createLabel')[1].color, '0052cc');
assert.equal(find(calls, 'react')[1].content, '+1');
assert.match(find(calls, 'comment')[1].body, /^Claimed by @alice\. .*`Closes #7`.*14 days/);
});
test('claim still labels and replies when assignment is refused', async () => {
const { github, calls } = fake({ fail: { addAssignees: 422 } });
await claim.handleComment({ github, context: ctx('/claim'), now: NOW });
assert.ok(find(calls, 'addLabels'));
assert.match(find(calls, 'comment')[1].body, /@alice/);
});
test('claim by the current claimant is a quiet thumbs up', async () => {
const { github, calls } = fake();
await claim.handleComment({ github, context: ctx('/claim', { assignees: ['alice'], labels: ['help wanted', 'claimed'] }), now: NOW });
assert.deepEqual(names(calls), ['react']);
});
test('claim on an issue claimed by someone else names them and when it lapses', async () => {
const { github, calls } = fake({ timeline: [labeled(ago(10 * H))] });
await claim.handleComment({ github, context: ctx('/claim', { assignees: ['bob'], labels: ['help wanted', 'claimed'] }), now: NOW });
assert.equal(find(calls, 'react')[1].content, 'eyes');
const body = find(calls, 'comment')[1].body;
assert.match(body, /@bob/);
assert.match(body, /2026-10-30 02:00 UTC/); // 10 h ago + 14 days
assert.equal(find(calls, 'addAssignees'), undefined);
});
test('a lapsed claim is taken over', async () => {
const { github, calls } = fake({ timeline: [labeled(ago(15 * D))] });
await claim.handleComment({ github, context: ctx('/claim', { assignees: ['bob'], labels: ['help wanted', 'claimed'] }), now: NOW });
assert.deepEqual(find(calls, 'removeAssignees')[1].assignees, ['bob']);
assert.deepEqual(find(calls, 'addAssignees')[1].assignees, ['alice']);
});
test('a claim with a recent PR is not taken over', async () => {
const { github, calls } = fake({ timeline: [labeled(ago(30 * D)), pr(50, 'bob')], pushed: ago(2 * H) });
await claim.handleComment({ github, context: ctx('/claim', { assignees: ['bob'], labels: ['help wanted', 'claimed'] }), now: NOW });
assert.equal(find(calls, 'addAssignees'), undefined);
});
test('a maintainer\'s claim is never taken over', async () => {
const { github, calls } = fake({ timeline: [labeled(ago(500 * H))], perms: { carol: 'write' } });
await claim.handleComment({ github, context: ctx('/claim', { assignees: ['carol'], labels: ['help wanted', 'claimed'] }), now: NOW });
assert.equal(find(calls, 'addAssignees'), undefined);
assert.equal(find(calls, 'react')[1].content, 'eyes');
});
test('an unclaimed issue with someone else\'s open PR points at the PR', async () => {
const { github, calls } = fake({ timeline: [pr(42, 'bob')] });
await claim.handleComment({ github, context: ctx('/claim'), now: NOW });
assert.equal(find(calls, 'addAssignees'), undefined);
assert.match(find(calls, 'comment')[1].body, /#42/);
});
test('bots, locked issues, closed issues, PRs and other comments are ignored', async () => {
const { github, calls } = fake();
const bot = ctx('/claim');
bot.payload.comment.user = { login: 'dependabot[bot]', type: 'Bot' };
assert.equal(await claim.handleComment({ github, context: bot, now: NOW }), 'skip');
assert.equal(await claim.handleComment({ github, context: ctx('/claim', { extra: { locked: true } }), now: NOW }), 'skip');
assert.equal(await claim.handleComment({ github, context: ctx('/claim', { extra: { state: 'closed' } }), now: NOW }), 'skip');
assert.equal(await claim.handleComment({ github, context: ctx('/claim', { extra: { pull_request: {} } }), now: NOW }), 'skip');
assert.equal(await claim.handleComment({ github, context: ctx('hello'), now: NOW }), 'skip');
assert.equal(calls.length, 0);
});
test('unclaim by the claimant releases', async () => {
const { github, calls } = fake();
await claim.handleComment({ github, context: ctx('/unclaim', { assignees: ['alice'], labels: ['help wanted', 'claimed'] }), now: NOW });
assert.deepEqual(find(calls, 'removeAssignees')[1].assignees, ['alice']);
assert.equal(find(calls, 'removeLabel')[1].name, 'claimed');
});
test('unclaim by a maintainer releases, by a stranger does not', async () => {
let w = fake({ perms: { carol: 'admin' } });
await claim.handleComment({ github: w.github, context: ctx('/unclaim', { user: 'carol', assignees: ['bob'], labels: ['help wanted', 'claimed'] }), now: NOW });
assert.ok(find(w.calls, 'removeLabel'));
w = fake();
await claim.handleComment({ github: w.github, context: ctx('/unclaim', { user: 'mallory', assignees: ['bob'], labels: ['help wanted', 'claimed'] }), now: NOW });
assert.equal(find(w.calls, 'removeLabel'), undefined);
assert.equal(find(w.calls, 'react')[1].content, 'eyes');
});
const issue = (number, assignees = ['bob'], extra = []) => ({
number,
locked: false,
updated_at: ago(1000 * H),
assignees: assignees.map((login) => ({ login })),
labels: [{ name: 'claimed' }, ...extra.map((name) => ({ name }))],
});
const repo = { owner: 'tangency-labs', repo: 'fieldcraft' };
test('sweep releases a claim older than 14 days with no PR', async () => {
const { github, calls } = fake({ issues: [issue(7)], timeline: [labeled(ago(15 * D))] });
assert.deepEqual(await claim.sweep({ github, context: { repo }, now: NOW }), [7]);
assert.ok(find(calls, 'removeLabel'));
assert.deepEqual(find(calls, 'removeAssignees')[1].assignees, ['bob']);
assert.match(find(calls, 'comment')[1].body, /free again/);
});
test('sweep keeps a young claim and a claim with a fresh PR, using the latest label time', async () => {
let w = fake({ issues: [issue(7)], timeline: [labeled(ago(30 * D)), labeled(ago(5 * D))] });
assert.deepEqual(await claim.sweep({ github: w.github, context: { repo }, now: NOW }), []);
w = fake({ issues: [issue(7)], timeline: [labeled(ago(30 * D)), pr(9)], pushed: ago(3 * D) });
assert.deepEqual(await claim.sweep({ github: w.github, context: { repo }, now: NOW }), []);
});
test('sweep releases a claim whose PR has been idle for 14 days', async () => {
const w = fake({ issues: [issue(7)], timeline: [labeled(ago(30 * D)), pr(9)], pushed: ago(15 * D) });
assert.deepEqual(await claim.sweep({ github: w.github, context: { repo }, now: NOW }), [7]);
assert.match(find(w.calls, 'comment')[1].body, /14 days/);
});
test('sweep ignores a closed PR', async () => {
const w = fake({ issues: [issue(7)], timeline: [labeled(ago(15 * D)), pr(9, 'other', 'closed')] });
assert.deepEqual(await claim.sweep({ github: w.github, context: { repo }, now: NOW }), [7]);
});
test('sweep never touches core-team or maintainer-claimed issues', async () => {
const w = fake({
issues: [issue(1, ['bob'], ['in progress']), issue(2, ['carol']), issue(3, ['bob', 'carol'])],
timeline: [labeled(ago(900 * H))],
perms: { carol: 'write' },
});
assert.deepEqual(await claim.sweep({ github: w.github, context: { repo }, now: NOW }), []);
assert.equal(w.calls.length, 0);
});
test('sweep is idempotent: a released issue is no longer listed', async () => {
const world = { issues: [issue(7)], timeline: [labeled(ago(15 * D))] };
const w = fake(world);
assert.deepEqual(await claim.sweep({ github: w.github, context: { repo }, now: NOW }), [7]);
world.issues = [];
assert.deepEqual(await claim.sweep({ github: w.github, context: { repo }, now: NOW }), []);
});
test('referencedIssues reads the title and closing keywords, capped', () => {
assert.deepEqual(claim.referencedIssues('Fillet: chain (#123)', 'Fixes #9, closes: #10, see #11'), [123, 9, 10]);
assert.deepEqual(claim.referencedIssues('no refs', 'mentions #4 only'), []);
assert.deepEqual(claim.referencedIssues('#1 #2 #3 #4 #5 #6 #7', ''), [1, 2, 3, 4, 5]);
assert.deepEqual(claim.referencedIssues(undefined, null), []);
});
const openIssue = (number, assignees = [], labels = ['help wanted']) => ({
number,
state: 'open',
locked: false,
assignees: assignees.map((login) => ({ login })),
labels: labels.map((name) => ({ name })),
});
const pullCtx = (number, title, body = '', login = 'alice') => ({
repo,
payload: { pull_request: { number, state: 'open', title, body, user: { login, type: 'User' } } },
});
test('a PR referencing a free issue claims it for the author', async () => {
const { github, calls } = fake({ byNumber: { 7: openIssue(7) } });
assert.deepEqual(await claim.handlePull({ github, context: pullCtx(50, 'Thing (#7)') }), [7]);
assert.deepEqual(find(calls, 'addAssignees')[1], { ...repo, issue_number: 7, assignees: ['alice'] });
assert.deepEqual(find(calls, 'addLabels')[1].labels, ['claimed']);
assert.match(find(calls, 'comment')[1].body, /Claimed by @alice through #50/);
});
test('a PR on an issue someone else holds comments on the PR and changes nothing', async () => {
const { github, calls } = fake({ byNumber: { 7: openIssue(7, ['bob'], ['help wanted', 'claimed']) } });
assert.deepEqual(await claim.handlePull({ github, context: pullCtx(50, 'Thing (#7)') }), []);
assert.equal(find(calls, 'addAssignees'), undefined);
const c = find(calls, 'comment')[1];
assert.equal(c.issue_number, 50);
assert.match(c.body, /@bob/);
});
test('the lowest-numbered open PR wins an unrecorded claim', async () => {
const { github, calls } = fake({ byNumber: { 7: openIssue(7) }, timeline: [pr(42, 'bob')] });
assert.deepEqual(await claim.handlePull({ github, context: pullCtx(50, 'Thing (#7)') }), []);
assert.match(find(calls, 'comment')[1].body, /#42 claimed #7 first/);
});
test('the claimant\'s own PR just makes sure the label is there', async () => {
const { github, calls } = fake({ byNumber: { 7: openIssue(7, ['alice']) } });
assert.deepEqual(await claim.handlePull({ github, context: pullCtx(50, 'Thing (#7)') }), []);
assert.deepEqual(names(calls), ['addLabels']);
});
test('PRs from bots, references to PRs, closed and missing issues are ignored', async () => {
const w = fake({
byNumber: { 7: { ...openIssue(7), pull_request: {} }, 8: { ...openIssue(8), state: 'closed' } },
});
assert.deepEqual(await claim.handlePull({ github: w.github, context: pullCtx(50, 'x (#7) (#8) (#9)') }), []);
const bot = pullCtx(51, 'x (#1)');
bot.payload.pull_request.user = { login: 'dependabot[bot]', type: 'Bot' };
assert.deepEqual(await claim.handlePull({ github: w.github, context: bot }), []);
assert.equal(w.calls.length, 0);
});
test('an issue the core team holds (`in progress`) cannot be claimed', async () => {
const { github, calls } = fake();
await claim.handleComment({ github, context: ctx('/claim', { labels: ['help wanted', 'in progress'] }), now: NOW });
assert.equal(find(calls, 'addAssignees'), undefined);
assert.match(find(calls, 'comment')[1].body, /core team/);
});
test('only help wanted / good first issue issues can be claimed, except by a maintainer', async () => {
let w = fake();
await claim.handleComment({ github: w.github, context: ctx('/claim', { labels: ['bug'] }), now: NOW });
assert.equal(find(w.calls, 'addAssignees'), undefined);
assert.match(find(w.calls, 'comment')[1].body, /help wanted/);
w = fake();
await claim.handleComment({ github: w.github, context: ctx('/claim', { labels: ['good first issue'] }), now: NOW });
assert.ok(find(w.calls, 'addAssignees'));
w = fake({ perms: { carol: 'write' } });
await claim.handleComment({ github: w.github, context: ctx('/claim', { user: 'carol', labels: [] }), now: NOW });
assert.deepEqual(find(w.calls, 'addAssignees')[1].assignees, ['carol']);
});
test('a PR on a core-team or not-yet-open issue comments on the PR and claims nothing', async () => {
const { github, calls } = fake({
byNumber: { 7: openIssue(7, [], ['help wanted', 'in progress']), 8: openIssue(8, [], ['bug']) },
});
assert.deepEqual(await claim.handlePull({ github, context: pullCtx(50, 'Thing (#7) (#8)') }), []);
assert.equal(find(calls, 'addLabels'), undefined);
const bodies = calls.filter((c) => c[0] === 'comment').map((c) => c[1].body);
assert.match(bodies[0], /core team/);
assert.match(bodies[1], /isn't open for contributors/);
});
test('a maintainer\'s PR claims nothing', async () => {
const { github, calls } = fake({ byNumber: { 7: openIssue(7) }, perms: { alice: 'admin' } });
assert.deepEqual(await claim.handlePull({ github, context: pullCtx(50, 'Thing (#7)') }), []);
assert.equal(calls.length, 0);
});
test('a claim whose PR is stale but which was claimed recently stands', async () => {
const w = fake({ issues: [issue(7)], timeline: [labeled(ago(2 * D)), pr(9)], pushed: ago(30 * D) });
assert.deepEqual(await claim.sweep({ github: w.github, context: { repo }, now: NOW }), []);
});
+178
View File
@@ -0,0 +1,178 @@
// `needs-info`: a short hand-written bug report without reproduction steps is asked for them once.
// The author's reply (or an edit that adds them) clears the label; the daily sweep closes what
// stays unanswered for INFO_DAYS. Reports filed from the app (Help › Report a Bug,
// crates/engine/src/bug_report.rs) carry their session, crash report and version, so they are
// never asked. Used by .github/workflows/triage.yml.
'use strict';
const { ensureLabel, isMaintainer, labelNames, section } = require('./triage.js');
const LABEL = 'needs-info';
const LABEL_COLOR = 'd4c5f9';
const LABEL_DESCRIPTION = 'waiting on the reporter for reproduction steps';
const INFO_DAYS = 14;
const DAY = 24 * 3600 * 1000;
const MAX_TEXT = 20000;
function repoArgs(context) {
return { owner: context.repo.owner, repo: context.repo.repo };
}
// What only the app's own reports carry (crates/engine/src/bug_report.rs).
const FROM_APP = /Replay the attached session|^Crash report `|^Backtrace \(top\):|^Session \(session\.json\):/m;
// Requests, questions and plans, which aren't reproduced.
const NOT_A_BUG = /^\s*\[(feat|feature|change|enhancement)|feature request|suggestion|\?\s*$|^\s*(tracker|refactor|support|add|allow|how|why|request|missing)\b/i;
// A report this long describes what it saw; only short ones are asked. Counted without the
// form's headings and its `_No response_` placeholders.
const SHORT = 300;
const ATTACHMENT = /user-attachments\/|\.(fieldcraft|solidcraft|inp|bdf|k|vtu|step|stp|3mf|stl|json)\b/i;
const COMMAND = /["`][a-z_]+\.[a-z_][a-z_.]*["`]|\bfieldcraft(-cli)?\s+(--sample|run|snapshot)\b/;
const VERSION = /\bv?\d+\.\d+\.\d+\b|\b[0-9a-f]{7,40}\b/i;
function prose(body) {
return body
.split('\n')
.filter((l) => !/^#{2,3}\s/.test(l) && l.trim() !== '_No response_')
.join(' ')
.replace(/\s+/g, ' ')
.trim();
}
// What a bug report lacks: [] when it can be reproduced.
function missing(title, body) {
const t = String(title || '');
const b = String(body || '').slice(0, MAX_TEXT).replace(/<!--[\s\S]*?-->/g, '');
if (NOT_A_BUG.test(t) || FROM_APP.test(b) || prose(b).length >= SHORT) return [];
const steps = `${section(b, 'Reproduction')}\n${section(b, 'Steps to reproduce')}`.replace(/_No response_/g, '');
const reproducible =
steps.replace(/\s+/g, '').length >= 30 || ATTACHMENT.test(b) || COMMAND.test(b) || /^\s*1[.)]\s/m.test(b);
if (reproducible) return [];
const asks = ['steps'];
if (!VERSION.test(`${t}\n${b}`)) asks.push('version');
return asks;
}
function askBody(asks) {
const lines = ['Thanks for the report! To reproduce it we need a little more:'];
lines.push(
'- **Steps to reproduce**: numbered steps from a sample (`fieldcraft --sample cantilever`) or a script ' +
'(`fieldcraft-cli run script.json`), and the `.fieldcraft` or `.solidcraft` file that shows it if it is your own model. ' +
'**Help › Report a Bug** in the app fills this in for you.',
);
if (asks.includes('version')) lines.push('- **FieldCraft version** (or commit) and your OS.');
lines.push(
'',
`Reply here or edit the issue and it goes back to the triage queue. If we don't hear back within ${INFO_DAYS} days it is closed; reopen it any time.`,
);
return lines.join('\n');
}
// What to ask the reporter of `issue`, or [] (maintainers' and bots' issues are never asked).
async function asksFor(github, context, issue, cache) {
if (issue.state !== 'open' || issue.pull_request || issue.locked) return [];
const names = labelNames(issue);
if (names.includes(LABEL) || names.includes('enhancement')) return []; // the feature form labels its issues
const asks = missing(issue.title, issue.body);
if (!asks.length || !issue.user || issue.user.type === 'Bot') return [];
const login = issue.user.login;
if (!cache.has(login)) cache.set(login, await isMaintainer(github, context, login));
return cache.get(login) ? [] : asks;
}
async function ask(github, context, issue, asks) {
await ensureLabel(github, context, LABEL, LABEL_COLOR, LABEL_DESCRIPTION);
await github.rest.issues.addLabels({ ...repoArgs(context), issue_number: issue.number, labels: [LABEL] });
await github.rest.issues.createComment({ ...repoArgs(context), issue_number: issue.number, body: askBody(asks) });
}
// issues.opened.
async function handleOpened({ github, context }) {
const issue = context.payload.issue;
if (!issue) return false;
const asks = await asksFor(github, context, issue, new Map());
if (!asks.length) return false;
await ask(github, context, issue, asks);
return true;
}
// Workflow dispatch: the same ask over the open issues filed before the bot. With `dryRun` it
// changes nothing and returns what it would ask.
async function backfill({ github, context, dryRun = true }) {
const issues = await github.paginate(github.rest.issues.listForRepo, {
...repoArgs(context),
state: 'open',
per_page: 100,
});
const cache = new Map();
const plan = [];
for (const issue of issues) {
const asks = await asksFor(github, context, issue, cache);
if (!asks.length) continue;
plan.push({ number: issue.number, title: issue.title, asks });
if (!dryRun) await ask(github, context, issue, asks);
}
return plan;
}
async function clear(github, context, number) {
try {
await github.rest.issues.removeLabel({ ...repoArgs(context), issue_number: number, name: LABEL });
} catch (e) {
if (e.status !== 404) throw e;
}
}
// issue_comment.created / issues.edited: the reporter answered.
async function handleReply({ github, context }) {
const { issue, comment } = context.payload;
if (!issue || !labelNames(issue).includes(LABEL)) return false;
const author = issue.user && issue.user.login;
const actor = comment ? comment.user.login : context.payload.sender && context.payload.sender.login;
if (!author || actor !== author) return false;
if (!comment && missing(issue.title, issue.body).length) return false; // an edit that still lacks steps
await clear(github, context, issue.number);
return true;
}
// When the label was last added, from the timeline; the issue's update time if it can't be found.
async function labeledAt(github, context, issue) {
const events = await github.paginate(github.rest.issues.listEventsForTimeline, {
...repoArgs(context),
issue_number: issue.number,
per_page: 100,
});
let at = null;
for (const ev of events) if (ev.event === 'labeled' && ev.label && ev.label.name === LABEL) at = ev.created_at;
return new Date(at || issue.updated_at).getTime();
}
// The daily sweep: close what has waited INFO_DAYS for its reporter.
async function sweep({ github, context, now = Date.now() }) {
const issues = await github.paginate(github.rest.issues.listForRepo, {
...repoArgs(context),
state: 'open',
labels: LABEL,
per_page: 100,
});
const closed = [];
for (const issue of issues) {
if (issue.pull_request || issue.locked) continue;
if (now - (await labeledAt(github, context, issue)) < INFO_DAYS * DAY) continue;
await github.rest.issues.createComment({
...repoArgs(context),
issue_number: issue.number,
body: `Closing for now: we couldn't reproduce this without more detail. Reopen it with steps to reproduce and we'll pick it up.`,
});
await github.rest.issues.update({
...repoArgs(context),
issue_number: issue.number,
state: 'closed',
state_reason: 'not_planned',
});
closed.push(issue.number);
}
return closed;
}
module.exports = { LABEL, missing, handleOpened, handleReply, sweep, backfill };
+145
View File
@@ -0,0 +1,145 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const info = require('./needs-info.js');
const repo = { owner: 'tangency-labs', repo: 'fieldcraft' };
const NOW = Date.parse('2026-10-18T12:00:00Z');
const DAY = 24 * 3600 * 1000;
const ago = (ms) => new Date(NOW - ms).toISOString();
function fake(world = {}) {
const calls = [];
const rec = (name) => async (args) => {
calls.push([name, args]);
return { data: {} };
};
const github = {
paginate: async (fn, args) => (await fn(args)).data,
rest: {
repos: {
getCollaboratorPermissionLevel: async ({ username }) => ({
data: { permission: (world.perms || {})[username] || 'none' },
}),
},
issues: {
getLabel: async () => ({ data: {} }),
createLabel: rec('createLabel'),
addLabels: rec('addLabels'),
removeLabel: rec('removeLabel'),
createComment: rec('comment'),
update: rec('update'),
listForRepo: async () => ({ data: world.issues || [] }),
listEventsForTimeline: async () => ({ data: world.timeline || [] }),
},
},
};
return { github, calls };
}
const find = (calls, n) => calls.find((c) => c[0] === n);
const labels = (names) => names.map((name) => ({ name }));
const long = 'x'.repeat(400);
const form = (observed, reproduction, version = '_No response_') =>
`### Observed failure\n\n${observed}\n\n### Reproduction\n\n${reproduction}\n\n### Evidence\n\n_No response_\n\n### Expected behaviour\n\nworks\n\n### Version and platform\n\n${version}`;
test('missing: short reports without steps are asked, with the version when it is absent', () => {
assert.deepEqual(info.missing('Solve does not work', 'The solver is not working.'), ['steps', 'version']);
assert.deepEqual(info.missing('Solve does not work', 'Broken on v0.6.0.'), ['steps']);
assert.deepEqual(info.missing('Hex mesh broken', form('it fails', 'mesh it')), ['steps', 'version']);
assert.deepEqual(info.missing('Hex mesh broken', form('it fails', 'mesh it', 'v0.6.0, macOS')), ['steps']);
});
test('missing: reproducible, long, app-filed and non-bug issues are left alone', () => {
assert.deepEqual(info.missing('Mesh fails', form('x', '1. fieldcraft --sample cantilever\n2. Mesh ▸ Tet')), []);
assert.deepEqual(info.missing('Mesh fails', 'see bracket.fieldcraft attached'), []);
assert.deepEqual(info.missing('Mesh fails', 'runs `mesh.tet.generate` and fails'), []);
assert.deepEqual(info.missing('Mesh fails', long), []);
assert.deepEqual(info.missing('Internal error in solve.static', form('x', 'Replay the attached session (3 command(s)): `fieldcraft-cli run session.json`.')), []);
assert.deepEqual(info.missing('FieldCraft closed unexpectedly', form('x', '', 'v0.6.0') + '\nCrash report `abc` (panic), seen 1 time(s)'), []);
assert.deepEqual(info.missing('Feature request: dark mode', 'please'), []);
assert.deepEqual(info.missing('[FEAT] composites', 'please'), []);
assert.deepEqual(info.missing('How do I apply a pressure?', ''), []);
});
const opened = (title, body, login = 'reporter') => ({
repo,
payload: { issue: { number: 4, state: 'open', title, body, labels: [], user: { login, type: 'User' } } },
});
test('an opened thin report is labelled and asked once', async () => {
const { github, calls } = fake();
assert.equal(await info.handleOpened({ github, context: opened('Solve broken', 'does not work') }), true);
assert.deepEqual(find(calls, 'addLabels')[1].labels, ['needs-info']);
const body = find(calls, 'comment')[1].body;
assert.match(body, /Steps to reproduce/);
assert.match(body, /FieldCraft version/);
assert.match(body, /14 days/);
});
test('a maintainer\'s note is never asked', async () => {
const w = fake({ perms: { jyviko: 'admin' } });
assert.equal(await info.handleOpened({ github: w.github, context: opened('Solve broken', 'x', 'jyviko') }), false);
assert.equal(w.calls.length, 0);
});
const reply = (login, { comment = true, body = 'x', issueLabels = ['needs-info'] } = {}) => ({
repo,
payload: {
issue: { number: 4, title: 'Solve broken', body, labels: labels(issueLabels), user: { login: 'reporter' } },
...(comment ? { comment: { user: { login } } } : { sender: { login } }),
},
});
test('the reporter\'s comment clears needs-info; someone else\'s does not', async () => {
let w = fake();
assert.equal(await info.handleReply({ github: w.github, context: reply('reporter') }), true);
assert.equal(find(w.calls, 'removeLabel')[1].name, 'needs-info');
w = fake();
assert.equal(await info.handleReply({ github: w.github, context: reply('bystander') }), false);
w = fake();
assert.equal(await info.handleReply({ github: w.github, context: reply('reporter', { issueLabels: [] }) }), false);
assert.equal(w.calls.length, 0);
});
test('an edit clears needs-info only once the steps are there', async () => {
let w = fake();
assert.equal(await info.handleReply({ github: w.github, context: reply('reporter', { comment: false, body: 'still broken' }) }), false);
w = fake();
const fixed = reply('reporter', { comment: false, body: '## Reproduction\n1. open the cantilever sample\n2. solve it' });
assert.equal(await info.handleReply({ github: w.github, context: fixed }), true);
});
test('the sweep closes what waited 14 days, and keeps younger ones', async () => {
const issue = { number: 4, locked: false, updated_at: ago(30 * DAY), labels: labels(['needs-info']) };
const labeled = (at) => ({ event: 'labeled', label: { name: 'needs-info' }, created_at: at });
let w = fake({ issues: [issue], timeline: [labeled(ago(15 * DAY))] });
assert.deepEqual(await info.sweep({ github: w.github, context: { repo }, now: NOW }), [4]);
assert.deepEqual(find(w.calls, 'update')[1], { ...repo, issue_number: 4, state: 'closed', state_reason: 'not_planned' });
w = fake({ issues: [issue], timeline: [labeled(ago(20 * DAY)), labeled(ago(3 * DAY))] });
assert.deepEqual(await info.sweep({ github: w.github, context: { repo }, now: NOW }), []);
assert.equal(w.calls.length, 0);
});
test('backfill asks open thin reports once, and the dry run writes nothing', async () => {
const issues = [
{ number: 1, state: 'open', title: 'Solve broken', body: 'nope', labels: [], user: { login: 'a', type: 'User' } },
{ number: 2, state: 'open', title: 'Solve broken', body: 'nope', labels: labels(['needs-info']), user: { login: 'a' } },
{ number: 3, state: 'open', title: 'x', body: 'nope', labels: [], pull_request: {}, user: { login: 'a' } },
];
let w = fake({ issues });
const plan = await info.backfill({ github: w.github, context: { repo }, dryRun: true });
assert.deepEqual(plan.map((p) => p.number), [1]);
assert.equal(w.calls.length, 0);
w = fake({ issues });
await info.backfill({ github: w.github, context: { repo }, dryRun: false });
assert.equal(find(w.calls, 'addLabels')[1].issue_number, 1);
});
test('an issue from the feature form is never asked for steps', async () => {
const { github, calls } = fake();
const ctx = opened('Dark mode', '### What you need and why\nwork at night');
ctx.payload.issue.labels = labels(['enhancement']);
assert.equal(await info.handleOpened({ github, context: ctx }), false);
assert.equal(calls.length, 0);
});
+315
View File
@@ -0,0 +1,315 @@
// The triage bot's logic, used by .github/workflows/triage.yml through actions/github-script.
// New issues and PRs get `area:*` and type labels from fixed rules; an issue nobody has given a
// priority yet carries `needs-triage`. Maintainers set labels with `/triage`. The rules only ever
// add labels, so a person's correction is never undone.
// Everything takes the github-script `github` client and `context`, so the tests can pass fakes.
'use strict';
const NEEDS_TRIAGE = 'needs-triage';
const NEEDS_TRIAGE_COLOR = 'ededed';
const NEEDS_TRIAGE_DESCRIPTION = 'no priority yet; a maintainer sorts it with /triage';
const MAX_TOKENS = 10;
const MAX_TEXT = 20000;
// Changed paths of a PR → area. First match wins per file.
const PATH_AREAS = [
[/^crates\/mesh\//, 'area:mesh'],
[/^crates\/lbm[^/]*\//, 'area:cfd'],
[/^crates\/(solver|fem|explicit)\//, 'area:solver'],
[/^(crates\/ui-egui|apps\/[^/]+)\//, 'area:ui'],
[/^crates\/(io|decks)\//, 'area:io'],
[/^(docs\/|[^/]+\.md$)/, 'area:docs'],
[/^(\.github\/|xtask\/|packaging\/|scripts\/|tools\/)/, 'area:infra'],
];
// Command id prefix (the in-app reports quote it: "Internal error in mesh.tet.generate") → area.
const COMMAND_AREAS = {
mesh: 'area:mesh',
geometry: 'area:mesh',
model: 'area:solver',
interaction: 'area:solver',
solve: 'area:solver',
file: 'area:io',
deck: 'area:io',
results: 'area:ui',
view: 'area:ui',
};
// Words in an issue's title → area. Conservative: a miss leaves the issue for a person.
const WORD_AREAS = [
[/\b(mesh(es|ing|er)?|tet(rahedral|s)?|hex(ahedral|es)?|octree|jacobian|sliver|element quality|remesh)\b/i, 'area:mesh'],
[/\b(lbm|lattice boltzmann|cfd|fluid|flow|turbulen(ce|t)|velocity field)\b/i, 'area:cfd'],
[/\b(solver?|solves?|solving|explicit|implicit|static|frequency|modal|buckling|contact|plasticity|hyperelastic|convergence|newton|material|thermal)\b/i, 'area:solver'],
[/\b(abaqus|nastran|ls-?dyna|deck|vtk|vtu|paraview|gmsh|step|stl|3mf|csv|import|export)\b/i, 'area:io'],
[/\b(toolbar|dialog|viewport|panel|button|shortcut|menu|window|legend|plot|theme)\b/i, 'area:ui'],
[/\b(docs?|documentation|readme|tutorial|typo)\b/i, 'area:docs'],
[/\b(ci|release|packaging|flatpak|installer|homebrew|winget)\b/i, 'area:infra'],
];
const CRASH = /^\s*crash\b|\bpanic(ked)?\b|^\s*internal error\b|\bclosed unexpectedly\b/i;
const FEATURE = /^\s*\[?feature( request)?\]?\b/i;
// The issue forms' own headings (.github/ISSUE_TEMPLATE): their labels are set by GitHub when the
// form is used, so these only matter for the backfill and for issues edited into shape.
const BUG_FORM = /^###\s*observed failure\s*$/im;
const FEATURE_FORM = /^###\s*what you need and why\s*$/im;
function repoArgs(context) {
return { owner: context.repo.owner, repo: context.repo.repo };
}
// The text under a `## <name>` heading (`###` in issue forms), up to the next heading.
function section(body, name) {
const lines = body.split('\n');
const start = lines.findIndex((l) => new RegExp(`^#{2,3}\\s*${name}\\s*$`, 'i').test(l.trim()));
if (start < 0) return '';
const rest = lines.slice(start + 1);
const end = rest.findIndex((l) => /^#{2,3}\s/.test(l));
return (end < 0 ? rest : rest.slice(0, end)).join('\n');
}
function labelNames(item) {
return (item.labels || []).map((l) => (typeof l === 'string' ? l : l.name));
}
function isPriority(name) {
return /^priority\s*:/.test(name);
}
function hasPriority(names) {
return names.some(isPriority);
}
// Labels for an issue from its title and body.
function classifyIssue(title, body) {
const t = String(title || '').slice(0, 500);
const b = String(body || '').slice(0, MAX_TEXT);
const labels = new Set();
if (CRASH.test(t) || BUG_FORM.test(b)) {
labels.add('bug');
} else if (FEATURE.test(t) || FEATURE_FORM.test(b)) {
labels.add('enhancement');
}
// The title names the feature; a command id in the title (the in-app report's) is the fallback.
// The body is not read: a report's session quotes every earlier command too.
let area = null;
for (const [re, a] of WORD_AREAS) {
if (re.test(t)) {
area = a;
break;
}
}
if (!area) {
const m = /\b([a-z_]+)\.[a-z_]+(\.[a-z_]+)*\b/.exec(t);
area = (m && COMMAND_AREAS[m[1]]) || null;
}
if (area) labels.add(area);
return [...labels];
}
// Labels for a PR from its changed paths.
function classifyPaths(paths) {
const labels = new Set();
for (const p of paths) {
for (const [re, area] of PATH_AREAS) {
if (re.test(p)) {
labels.add(area);
break;
}
}
}
return [...labels].sort();
}
async function ensureLabel(github, context, name, color, description) {
try {
await github.rest.issues.getLabel({ ...repoArgs(context), name });
} catch (e) {
if (e.status !== 404) throw e;
try {
await github.rest.issues.createLabel({ ...repoArgs(context), name, color, description });
} catch (e2) {
if (e2.status !== 422) throw e2; // created meanwhile
}
}
}
async function pullPaths(github, context, number) {
const files = await github.paginate(github.rest.pulls.listFiles, {
...repoArgs(context),
pull_number: number,
per_page: 100,
});
return files.map((f) => f.filename);
}
// The labels the rules would add to an issue or PR that it doesn't already carry.
async function proposal(github, context, item) {
const current = labelNames(item);
let wanted;
if (item.pull_request) {
wanted = classifyPaths(await pullPaths(github, context, item.number));
} else {
wanted = classifyIssue(item.title, item.body);
if (!hasPriority(current) && !hasPriority(wanted)) wanted.push(NEEDS_TRIAGE);
}
return wanted.filter((l) => !current.includes(l));
}
async function apply(github, context, number, labels) {
if (!labels.length) return;
if (labels.includes(NEEDS_TRIAGE)) {
await ensureLabel(github, context, NEEDS_TRIAGE, NEEDS_TRIAGE_COLOR, NEEDS_TRIAGE_DESCRIPTION);
}
await github.rest.issues.addLabels({ ...repoArgs(context), issue_number: number, labels });
}
// issues.opened / pull_request_target.opened.
async function handleOpened({ github, context }) {
const issue = context.payload.issue;
const pull = context.payload.pull_request;
const item = issue || (pull && { ...pull, pull_request: {} });
if (!item || item.state !== 'open') return [];
const labels = await proposal(github, context, item);
await apply(github, context, item.number, labels);
return labels;
}
// issues.labeled: a priority settles triage.
async function handleLabeled({ github, context }) {
const issue = context.payload.issue;
const label = context.payload.label;
if (!issue || !label || !isPriority(label.name)) return false;
if (!labelNames(issue).includes(NEEDS_TRIAGE)) return false;
try {
await github.rest.issues.removeLabel({ ...repoArgs(context), issue_number: issue.number, name: NEEDS_TRIAGE });
} catch (e) {
if (e.status !== 404) throw e;
}
return true;
}
// `/triage area:mesh high S bug -enhancement`: tokens name existing labels, matched without
// regard to case, `-`/`_` for spaces or the space after a colon (`in-progress`, `size:S`); `high`
// is short for `priority: high` and `S`/`M`/`L` for `size: S`…, each replacing any other label of
// its kind; a leading `-` removes.
const ALIASES = { high: 'priority: high', s: 'size: S', m: 'size: M', l: 'size: L' };
function parseTriage(body) {
const line = String(body || '').trimStart().split('\n')[0];
const m = /^\/triage(\s.*)?$/.exec(line.trimEnd());
if (!m) return null;
const add = [];
const remove = [];
for (const raw of (m[1] || '').trim().split(/\s+/).filter(Boolean).slice(0, MAX_TOKENS)) {
const minus = raw.startsWith('-');
let name = minus ? raw.slice(1) : raw;
name = ALIASES[name.toLowerCase()] || name;
if (name) (minus ? remove : add).push(name);
}
return { add, remove };
}
function normalize(name) {
return String(name).toLowerCase().replace(/[-_\s]+/g, ' ').replace(/\s*:\s*/g, ':').trim();
}
// The label kind that replaces its siblings: `priority` or `size`.
function kind(name) {
const m = /^(priority|size)\s*:/.exec(name);
return m ? m[1] : null;
}
async function isMaintainer(github, context, login) {
if (login === context.repo.owner) return true;
try {
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ ...repoArgs(context), username: login });
return ['admin', 'write', 'maintain'].includes(data.permission);
} catch (e) {
return false;
}
}
async function react(github, context, content) {
await github.rest.reactions.createForIssueComment({
...repoArgs(context),
comment_id: context.payload.comment.id,
content,
});
}
// issue_comment.created.
async function handleComment({ github, context }) {
const { issue, comment } = context.payload;
if (!issue || !comment || comment.user.type === 'Bot' || comment.user.login.endsWith('[bot]')) return 'skip';
const cmd = parseTriage(comment.body);
if (!cmd) return 'skip';
if (!(await isMaintainer(github, context, comment.user.login))) {
await react(github, context, 'eyes');
return 'denied';
}
const known = new Map(
(await github.paginate(github.rest.issues.listLabelsForRepo, { ...repoArgs(context), per_page: 100 })).map((l) => [
normalize(l.name),
l.name,
]),
);
const resolve = (n) => known.get(normalize(n));
const unknown = [...cmd.add, ...cmd.remove].filter((n) => !resolve(n));
if (unknown.length) {
await react(github, context, 'confused');
await github.rest.issues.createComment({
...repoArgs(context),
issue_number: issue.number,
body: `No such label: ${unknown.map((n) => '`' + n + '`').join(', ')}. Nothing was changed.`,
});
return 'unknown';
}
const addNames = cmd.add.map(resolve);
const current = labelNames(issue);
const remove = new Set(cmd.remove.map(resolve));
for (const k of new Set(addNames.map(kind).filter(Boolean))) {
for (const n of current) if (kind(n) === k && !addNames.includes(n)) remove.add(n);
}
if (addNames.length || remove.size) remove.add(NEEDS_TRIAGE);
for (const name of remove) {
if (!current.includes(name)) continue;
await github.rest.issues.removeLabel({ ...repoArgs(context), issue_number: issue.number, name });
}
const add = addNames.filter((n) => !current.includes(n));
if (add.length) await github.rest.issues.addLabels({ ...repoArgs(context), issue_number: issue.number, labels: add });
await react(github, context, '+1');
return 'triage';
}
// Workflow dispatch: the rules over every open issue and PR with no labels at all. With `dryRun`
// it changes nothing and returns what it would do.
async function backfill({ github, context, dryRun = true }) {
const items = await github.paginate(github.rest.issues.listForRepo, {
...repoArgs(context),
state: 'open',
per_page: 100,
});
const plan = [];
for (const item of items) {
if (labelNames(item).length || item.locked) continue;
const labels = await proposal(github, context, item);
plan.push({ number: item.number, pull: Boolean(item.pull_request), title: item.title, labels });
if (!dryRun) await apply(github, context, item.number, labels);
}
return plan;
}
module.exports = {
NEEDS_TRIAGE,
classifyIssue,
classifyPaths,
ensureLabel,
isMaintainer,
labelNames,
section,
parseTriage,
handleOpened,
handleLabeled,
handleComment,
backfill,
};
+202
View File
@@ -0,0 +1,202 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const triage = require('./triage.js');
const repo = { owner: 'tangency-labs', repo: 'fieldcraft' };
// A fake github-script client that records writes. `world` describes the repo.
function fake(world = {}) {
const calls = [];
const rec = (name) => async (args) => {
calls.push([name, args]);
return { data: {} };
};
const github = {
paginate: async (fn, args) => (await fn(args)).data,
rest: {
repos: {
getCollaboratorPermissionLevel: async ({ username }) => ({
data: { permission: (world.perms || {})[username] || 'none' },
}),
},
pulls: { listFiles: async () => ({ data: (world.files || []).map((filename) => ({ filename })) }) },
reactions: { createForIssueComment: rec('react') },
issues: {
getLabel: async () => {
if (world.labelMissing) throw Object.assign(new Error('nf'), { status: 404 });
return { data: {} };
},
createLabel: rec('createLabel'),
addLabels: rec('addLabels'),
removeLabel: rec('removeLabel'),
createComment: rec('comment'),
listForRepo: async () => ({ data: world.items || [] }),
listLabelsForRepo: async () => ({
data: (world.labels || ['bug', 'enhancement', 'area:mesh', 'priority: high', 'size: S', 'size: M', 'in progress', 'needs-triage']).map(
(name) => ({ name }),
),
}),
},
},
};
return { github, calls };
}
const find = (calls, n) => calls.find((c) => c[0] === n);
const all = (calls, n) => calls.filter((c) => c[0] === n);
const labels = (names) => names.map((name) => ({ name }));
test('classifyIssue: in-app reports get bug and the area from the title or its command', () => {
assert.deepEqual(triage.classifyIssue('Internal error in mesh.tet.generate', ''), ['bug', 'area:mesh']);
assert.deepEqual(triage.classifyIssue('FieldCraft closed unexpectedly in solve.static', ''), ['bug', 'area:solver']);
assert.deepEqual(triage.classifyIssue('Internal error in results.show', ''), ['bug', 'area:ui']);
assert.deepEqual(triage.classifyIssue('Crash on startup', ''), ['bug']);
});
test('classifyIssue: the forms give the type, the title the area', () => {
const bug = '### Observed failure\nwrong\n\n### Reproduction\n1. x\n';
assert.deepEqual(triage.classifyIssue('Lattice Boltzmann diverges at Re 200', bug), ['bug', 'area:cfd']);
const feat = '### Current behaviour\nnone\n\n### What you need and why\nVTU\n';
assert.deepEqual(triage.classifyIssue('ParaView export', feat), ['enhancement', 'area:io']);
assert.deepEqual(triage.classifyIssue('Feature request: dark theme', ''), ['enhancement', 'area:ui']);
assert.deepEqual(triage.classifyIssue('Hex meshing of thin walls', ''), ['area:mesh']);
assert.deepEqual(triage.classifyIssue('Typo in the docs', ''), ['area:docs']);
});
test('classifyIssue: the body\'s session never gives the area, unmatched text gets nothing', () => {
assert.deepEqual(triage.classifyIssue('Wrong numbers', '"mesh.tet.generate"\n"solve.static"'), []);
assert.deepEqual(triage.classifyIssue('Refactor with types', 'words'), []);
assert.deepEqual(triage.classifyIssue(undefined, undefined), []);
});
test('classifyPaths maps changed files to areas', () => {
assert.deepEqual(
triage.classifyPaths([
'crates/mesh/src/a.rs',
'crates/lbm-gpu/b.rs',
'crates/fem/c.rs',
'apps/fieldcraft-cli/d.rs',
'crates/decks/e.rs',
'docs/oracle.md',
'README.md',
'.github/workflows/ci.yml',
'Cargo.lock',
]),
['area:cfd', 'area:docs', 'area:infra', 'area:io', 'area:mesh', 'area:solver', 'area:ui'],
);
assert.deepEqual(triage.classifyPaths(['crates/engine/src/cmd/x.rs']), []);
});
test('parseTriage', () => {
assert.deepEqual(triage.parseTriage('/triage area:mesh high S -enhancement'), {
add: ['area:mesh', 'priority: high', 'size: S'],
remove: ['enhancement'],
});
assert.deepEqual(triage.parseTriage('/triage'), { add: [], remove: [] });
assert.equal(triage.parseTriage('/triaged'), null);
assert.equal(triage.parseTriage('please /triage'), null);
assert.equal(triage.parseTriage('/triage ' + 'bug '.repeat(50)).add.length, 10);
});
test('an opened issue gets its labels plus needs-triage, which is created if missing', async () => {
const { github, calls } = fake({ labelMissing: true });
const issue = { number: 5, state: 'open', title: 'Internal error in mesh.hex.octree', body: '', labels: [] };
assert.deepEqual(await triage.handleOpened({ github, context: { repo, payload: { issue } } }), [
'bug',
'area:mesh',
'needs-triage',
]);
assert.ok(find(calls, 'createLabel'));
assert.deepEqual(find(calls, 'addLabels')[1].labels, ['bug', 'area:mesh', 'needs-triage']);
});
test('an opened issue keeps the labels it was filed with and skips needs-triage with a priority', async () => {
const { github, calls } = fake();
const issue = { number: 5, state: 'open', title: 'Solver diverges', body: '', labels: labels(['bug', 'priority: high']) };
assert.deepEqual(await triage.handleOpened({ github, context: { repo, payload: { issue } } }), ['area:solver']);
assert.deepEqual(find(calls, 'addLabels')[1].labels, ['area:solver']);
});
test('an opened PR is labelled from its paths and never gets needs-triage', async () => {
const { github, calls } = fake({ files: ['crates/mesh/src/tet.rs'] });
const pull_request = { number: 9, state: 'open', title: 'Mesh: thing', body: '', labels: [] };
assert.deepEqual(await triage.handleOpened({ github, context: { repo, payload: { pull_request } } }), ['area:mesh']);
assert.deepEqual(find(calls, 'addLabels')[1], { ...repo, issue_number: 9, labels: ['area:mesh'] });
});
test('a priority label clears needs-triage', async () => {
const { github, calls } = fake();
const issue = { number: 5, labels: labels(['needs-triage', 'priority: high']) };
assert.equal(await triage.handleLabeled({ github, context: { repo, payload: { issue, label: { name: 'priority: high' } } } }), true);
assert.equal(find(calls, 'removeLabel')[1].name, 'needs-triage');
const other = fake();
assert.equal(
await triage.handleLabeled({ github: other.github, context: { repo, payload: { issue, label: { name: 'bug' } } } }),
false,
);
assert.equal(other.calls.length, 0);
});
const comment = (body, login = 'carol', issueLabels = []) => ({
repo,
payload: {
issue: { number: 5, labels: labels(issueLabels) },
comment: { id: 1, body, user: { login, type: 'User' } },
},
});
test('/triage by a maintainer sets labels, replaces the size and clears needs-triage', async () => {
const { github, calls } = fake({ perms: { jyviko: 'admin' } });
const ctx = comment('/triage area:mesh HIGH S', 'jyviko', ['size: M', 'needs-triage', 'bug']);
assert.equal(await triage.handleComment({ github, context: ctx }), 'triage');
assert.deepEqual(all(calls, 'removeLabel').map((c) => c[1].name).sort(), ['needs-triage', 'size: M']);
assert.deepEqual(find(calls, 'addLabels')[1].labels, ['area:mesh', 'priority: high', 'size: S']);
assert.equal(find(calls, 'react')[1].content, '+1');
});
test('/triage matches labels with spaces written with dashes', async () => {
const { github, calls } = fake({ perms: { jyviko: 'admin' } });
assert.equal(await triage.handleComment({ github, context: comment('/triage -in-progress', 'jyviko', ['in progress']) }), 'triage');
assert.equal(find(calls, 'removeLabel')[1].name, 'in progress');
});
test('/triage from a non-maintainer or with an unknown label changes nothing', async () => {
let w = fake();
assert.equal(await triage.handleComment({ github: w.github, context: comment('/triage bug', 'mallory') }), 'denied');
assert.equal(find(w.calls, 'addLabels'), undefined);
w = fake({ perms: { carol: 'admin' } });
assert.equal(await triage.handleComment({ github: w.github, context: comment('/triage area:nope') }), 'unknown');
assert.equal(find(w.calls, 'addLabels'), undefined);
assert.match(find(w.calls, 'comment')[1].body, /area:nope/);
});
test('bots and other comments are ignored', async () => {
const { github, calls } = fake();
const bot = comment('/triage bug');
bot.payload.comment.user = { login: 'x[bot]', type: 'Bot' };
assert.equal(await triage.handleComment({ github, context: bot }), 'skip');
assert.equal(await triage.handleComment({ github, context: comment('hello') }), 'skip');
assert.equal(calls.length, 0);
});
test('backfill only touches unlabelled items, and the dry run writes nothing', async () => {
const items = [
{ number: 1, title: 'Internal error in mesh.tet.generate', body: '', labels: [] },
{ number: 2, title: 'Already sorted', body: '', labels: labels(['bug']) },
{ number: 3, title: 'x', body: '', labels: [], pull_request: {} },
];
let w = fake({ items, files: ['crates/io/src/step.rs'] });
const plan = await triage.backfill({ github: w.github, context: { repo }, dryRun: true });
assert.deepEqual(
plan.map((p) => [p.number, p.labels]),
[
[1, ['bug', 'area:mesh', 'needs-triage']],
[3, ['area:io']],
],
);
assert.equal(w.calls.length, 0);
w = fake({ items, files: ['crates/io/src/step.rs'] });
await triage.backfill({ github: w.github, context: { repo }, dryRun: false });
assert.deepEqual(all(w.calls, 'addLabels').map((c) => c[1].issue_number), [1, 3]);
});
+110
View File
@@ -0,0 +1,110 @@
# NAFEMS LE2 cylindrical shell bending patch test (MITC4 shells)
Our model of the published problem: a 30° sector of a cylindrical shell of mid-surface radius
1000 mm, axial length 500 mm and thickness 10 mm, E = 210 GPa, ν = 0.3 (N, mm, MPa). The straight
edge AB is clamped, the curved edges AD and BC keep their axial displacement (planes of symmetry
across the axis), and the other straight edge DC carries the load. Two load cases, one step each:
1. **Moment**: a uniform edge moment of 1000 N·mm/mm on DC about the axis;
2. **Pressure**: 0.6 MPa outward on the mid-surface, with a hoop traction of 60 MPa on DC (600 N/mm
over the thickness), the membrane force that balances it.
Both put the patch in a uniform state with a hoop stress of 60 MPa on the outer surface (6M/t² in
bending, pR/t in membrane), the published target at E. The descriptions we read do not place E;
since the exact stress is the same everywhere on the patch, we read it at the patch centre
(250, 0, 1000), the node on the y = 0 plane where the hoop direction is the global y axis, so the
tangential stress is σ_yy.
## The mesh
The patch is curved, so the recipe writes its 81 nodes and 64 quad4 elements one by one with
`mesh.node.add` and `mesh.element.add` ([docs/verification.md](../../../docs/verification.md)).
The axis is x and the arc runs from φ = −15° (AB) to φ = 15° (DC): node 1 + i + 9 j lies at
(500 i/8, 1000 sin φ_j, 1000 cos φ_j) with φ_j = −15° + 30° j/8, and element 1 + i + 8 j is
[node(i, j), node(i + 1, j), node(i + 1, j + 1), node(i, j + 1)], so its normal points away from
the axis and the shell's top surface is the outer one (the surface nodal stresses report).
The edge moment and edge traction are written as consistent nodal loads on DC: an end node of the
edge takes half of an inner node's share (two loads with `"distribution": "per_node"`). The
pressure acts on the elements' inner faces (face 2, the reversed node order), pushing outward.
The recipe was generated with this script (`python3 le2.py 8 > recipe.json`):
```python
import json, math, sys
def le2(n):
"""NAFEMS LE2 cylindrical patch (mm, N, MPa): R = 1000, axial length 500, 30° arc from φ = −15° (AB) to 15° (DC)."""
m = n # elements round the arc, as many as along the axis
R, L, half = 1000.0, 500.0, math.radians(15.0)
label = lambda i, j: 1 + i + (n + 1) * j
commands = []
for j in range(m + 1):
for i in range(n + 1):
phi = -half + 2 * half * j / m
commands.append({"command": "mesh.node.add", "params": {"label": label(i, j), "xyz": [L * i / n, R * math.sin(phi), R * math.cos(phi)]}})
for j in range(m):
for i in range(n):
# i along the axis, j round the arc: e3 points away from the axis, so top is the outer surface
nodes = [label(i, j), label(i + 1, j), label(i + 1, j + 1), label(i, j + 1)]
commands.append({"command": "mesh.element.add", "params": {"label": 1 + i + n * j, "kind": "quad4", "nodes": nodes}})
h = L / n
ends = [label(0, m), label(n, m)]
inner = [label(i, m) for i in range(1, n)]
t = [0.0, math.cos(half), -math.sin(half)] # the hoop direction at DC, away from AB
return commands + [
{"command": "model.set.create", "params": {"name": "patch", "elements": list(range(1, n * m + 1))}},
{"command": "model.set.create", "params": {"name": "inside", "faces": [{"element": e, "face": 2} for e in range(1, n * m + 1)]}},
{"command": "model.set.create", "params": {"name": "AB", "nodes": [label(i, 0) for i in range(n + 1)]}},
{"command": "model.set.create", "params": {"name": "AD_BC", "nodes": sorted([label(0, j) for j in range(m + 1)] + [label(n, j) for j in range(m + 1)])}},
{"command": "model.set.create", "params": {"name": "DC_ends", "nodes": ends}},
{"command": "model.set.create", "params": {"name": "DC_inner", "nodes": inner}},
{"command": "model.material.create", "params": {"name": "Steel", "elastic": {"type": "isotropic", "young": 210000.0, "poisson": 0.3}}},
{"command": "model.section.create", "params": {"name": "patch", "set": "patch", "material": "Steel", "kind": {"type": "shell", "thickness": 10.0}}},
{"command": "model.boundary_condition.create", "params": {"name": "AB", "type": "displacement", "target": {"set": "AB"}, "values": [0, 0, 0, 0, 0, 0]}},
{"command": "model.boundary_condition.create", "params": {"name": "AD_BC", "type": "symmetry", "target": {"set": "AD_BC"}, "plane": "x"}},
# load case 1: an edge moment of 1000 N·mm/mm on DC about the axis, as consistent nodal moments
{"command": "model.load.create", "params": {"name": "moment_ends", "type": "moment", "target": {"set": "DC_ends"}, "vector": [-1000.0 * h / 2, 0, 0], "distribution": "per_node"}},
{"command": "model.load.create", "params": {"name": "moment_inner", "type": "moment", "target": {"set": "DC_inner"}, "vector": [-1000.0 * h, 0, 0], "distribution": "per_node"}},
# load case 2: 0.6 MPa outward on the mid-surface, 60 MPa × 10 mm hoop traction on DC
{"command": "model.load.create", "params": {"name": "pressure", "type": "pressure", "set": "inside", "magnitude": 0.6}},
{"command": "model.load.create", "params": {"name": "pull_ends", "type": "force", "target": {"set": "DC_ends"}, "vector": [0.0, 600.0 * h / 2 * t[1], 600.0 * h / 2 * t[2]], "distribution": "per_node"}},
{"command": "model.load.create", "params": {"name": "pull_inner", "type": "force", "target": {"set": "DC_inner"}, "vector": [0.0, 600.0 * h * t[1], 600.0 * h * t[2]], "distribution": "per_node"}},
{"command": "model.step.create", "params": {"name": "Moment", "procedure": {"type": "static"}, "boundary_conditions": ["AB", "AD_BC"], "loads": ["moment_ends", "moment_inner"]}},
{"command": "model.step.create", "params": {"name": "Pressure", "procedure": {"type": "static"}, "boundary_conditions": ["AB", "AD_BC"], "loads": ["pressure", "pull_ends", "pull_inner"]}},
{"command": "solve.run", "params": {}, "id": "solve"},
]
n = int(sys.argv[1]) if len(sys.argv) > 1 else 8
print("[\n " + ",\n ".join(json.dumps(c) for c in le2(n)) + "\n]")
```
## Refinement
Every row is the listing above with the row's divisions, solved and probed at E as the case
does; the case mesh is 8 × 8:
| cells | σ_yy at E, moment | error | σ_yy at E, pressure | error |
|---|---|---|---|---|
| 4 × 4 | 59.743 | −0.43% | 59.615 | −0.64% |
| 8 × 8 (this case) | 59.936 | −0.11% | 59.904 | −0.16% |
| 16 × 16 | 59.984 | −0.03% | 59.976 | −0.04% |
| 32 × 32 | 59.996 | −0.01% | 59.994 | −0.01% |
The error falls as the square of the facet angle: each flat element is tilted by half its arc
angle from the hoop direction at E, so a share sin²(Δφ/2) of the hoop stress shows in σ_zz instead
(σ_yy + σ_zz is 60.000 in every row of load case 1). The next refinement of the case mesh moves the
value by 0.05% (moment) and 0.07% (pressure), well under a tenth of the 1% tolerance.
## Tolerance
1%: the target is exact for a thin shell and FieldCraft converges to it; the 8 × 8 mesh carries
a 0.1–0.2% facet error, a fifth of the tolerance or less. `fixture.json` holds the 8 × 8 values:
the mesh is written by the recipe, so it is bit-identical on every platform.
## Running it
```sh
cargo xtask oracle --case nafems-le2
cargo xtask oracle --bless nafems-le2 # only after the case passes
```
+23
View File
@@ -0,0 +1,23 @@
{
"title": "NAFEMS LE2 cylindrical shell bending patch test (MITC4 shells)",
"kind": "benchmark",
"source": "NAFEMS, The Standard NAFEMS Benchmarks, TNSB Rev. 3, October 1990, test LE2 (target: tangential stress 60 MPa on the outer surface at E, both load cases); the problem and target as reproduced in the Abaqus 2016 Benchmarks Guide, section 4.2.2 'LE2: Cylindrical shell bending patch test', and in the ESRD StressCheck Benchmarks Guide 'The Standard NAFEMS Benchmarks: Linear Elastic Tests' (2018), p. 8",
"quantities": [
{
"id": "stt_E_moment",
"quantity": "tangential stress at E, outer surface, load case 1 (edge moment)",
"measure": {"command": "solve.probe", "params": {"step": "Moment", "at": [[250, 0, 1000]]}, "pointer": "/nodes/0/stress/1"},
"value": 60.0,
"tolerance": {"relative": 0.01},
"reason": "the target is the exact thin-shell value (6M/t²) and the stress is uniform over the patch; the 8 × 8 flat facets tilt the element axes by ±1.875° from the hoop direction, which moves 0.1% of it into σ_zz, and 16 × 16 is within 0.03% (see README)"
},
{
"id": "stt_E_pressure",
"quantity": "tangential stress at E, outer surface, load case 2 (pressure and edge traction)",
"measure": {"command": "solve.probe", "params": {"step": "Pressure", "at": [[250, 0, 1000]]}, "pointer": "/nodes/0/stress/1"},
"value": 60.0,
"tolerance": {"relative": 0.01},
"reason": "the target is the exact membrane value (pR/t) and the stress is uniform over the patch; as in load case 1 the facets' tilt and the pressure on flat facets keep 8 × 8 within 0.2%, 16 × 16 within 0.05% (see README)"
}
]
}
+13
View File
@@ -0,0 +1,13 @@
{
"note": "FieldCraft's accepted results for this case, written by `cargo xtask oracle --bless`. A result outside its band fails CI.",
"values": {
"stt_E_moment": {
"band": 6.000000000000001e-8,
"value": 59.93576769689798
},
"stt_E_pressure": {
"band": 6.000000000000001e-8,
"value": 59.90367733652293
}
}
}
+165
View File
@@ -0,0 +1,165 @@
[
{"command": "mesh.node.add", "params": {"label": 1, "xyz": [0.0, -258.81904510252076, 965.9258262890684]}},
{"command": "mesh.node.add", "params": {"label": 2, "xyz": [62.5, -258.81904510252076, 965.9258262890684]}},
{"command": "mesh.node.add", "params": {"label": 3, "xyz": [125.0, -258.81904510252076, 965.9258262890684]}},
{"command": "mesh.node.add", "params": {"label": 4, "xyz": [187.5, -258.81904510252076, 965.9258262890684]}},
{"command": "mesh.node.add", "params": {"label": 5, "xyz": [250.0, -258.81904510252076, 965.9258262890684]}},
{"command": "mesh.node.add", "params": {"label": 6, "xyz": [312.5, -258.81904510252076, 965.9258262890684]}},
{"command": "mesh.node.add", "params": {"label": 7, "xyz": [375.0, -258.81904510252076, 965.9258262890684]}},
{"command": "mesh.node.add", "params": {"label": 8, "xyz": [437.5, -258.81904510252076, 965.9258262890684]}},
{"command": "mesh.node.add", "params": {"label": 9, "xyz": [500.0, -258.81904510252076, 965.9258262890684]}},
{"command": "mesh.node.add", "params": {"label": 10, "xyz": [0.0, -195.09032201612825, 980.7852804032304]}},
{"command": "mesh.node.add", "params": {"label": 11, "xyz": [62.5, -195.09032201612825, 980.7852804032304]}},
{"command": "mesh.node.add", "params": {"label": 12, "xyz": [125.0, -195.09032201612825, 980.7852804032304]}},
{"command": "mesh.node.add", "params": {"label": 13, "xyz": [187.5, -195.09032201612825, 980.7852804032304]}},
{"command": "mesh.node.add", "params": {"label": 14, "xyz": [250.0, -195.09032201612825, 980.7852804032304]}},
{"command": "mesh.node.add", "params": {"label": 15, "xyz": [312.5, -195.09032201612825, 980.7852804032304]}},
{"command": "mesh.node.add", "params": {"label": 16, "xyz": [375.0, -195.09032201612825, 980.7852804032304]}},
{"command": "mesh.node.add", "params": {"label": 17, "xyz": [437.5, -195.09032201612825, 980.7852804032304]}},
{"command": "mesh.node.add", "params": {"label": 18, "xyz": [500.0, -195.09032201612825, 980.7852804032304]}},
{"command": "mesh.node.add", "params": {"label": 19, "xyz": [0.0, -130.52619222005157, 991.4448613738103]}},
{"command": "mesh.node.add", "params": {"label": 20, "xyz": [62.5, -130.52619222005157, 991.4448613738103]}},
{"command": "mesh.node.add", "params": {"label": 21, "xyz": [125.0, -130.52619222005157, 991.4448613738103]}},
{"command": "mesh.node.add", "params": {"label": 22, "xyz": [187.5, -130.52619222005157, 991.4448613738103]}},
{"command": "mesh.node.add", "params": {"label": 23, "xyz": [250.0, -130.52619222005157, 991.4448613738103]}},
{"command": "mesh.node.add", "params": {"label": 24, "xyz": [312.5, -130.52619222005157, 991.4448613738103]}},
{"command": "mesh.node.add", "params": {"label": 25, "xyz": [375.0, -130.52619222005157, 991.4448613738103]}},
{"command": "mesh.node.add", "params": {"label": 26, "xyz": [437.5, -130.52619222005157, 991.4448613738103]}},
{"command": "mesh.node.add", "params": {"label": 27, "xyz": [500.0, -130.52619222005157, 991.4448613738103]}},
{"command": "mesh.node.add", "params": {"label": 28, "xyz": [0.0, -65.40312923014305, 997.8589232386034]}},
{"command": "mesh.node.add", "params": {"label": 29, "xyz": [62.5, -65.40312923014305, 997.8589232386034]}},
{"command": "mesh.node.add", "params": {"label": 30, "xyz": [125.0, -65.40312923014305, 997.8589232386034]}},
{"command": "mesh.node.add", "params": {"label": 31, "xyz": [187.5, -65.40312923014305, 997.8589232386034]}},
{"command": "mesh.node.add", "params": {"label": 32, "xyz": [250.0, -65.40312923014305, 997.8589232386034]}},
{"command": "mesh.node.add", "params": {"label": 33, "xyz": [312.5, -65.40312923014305, 997.8589232386034]}},
{"command": "mesh.node.add", "params": {"label": 34, "xyz": [375.0, -65.40312923014305, 997.8589232386034]}},
{"command": "mesh.node.add", "params": {"label": 35, "xyz": [437.5, -65.40312923014305, 997.8589232386034]}},
{"command": "mesh.node.add", "params": {"label": 36, "xyz": [500.0, -65.40312923014305, 997.8589232386034]}},
{"command": "mesh.node.add", "params": {"label": 37, "xyz": [0.0, 0.0, 1000.0]}},
{"command": "mesh.node.add", "params": {"label": 38, "xyz": [62.5, 0.0, 1000.0]}},
{"command": "mesh.node.add", "params": {"label": 39, "xyz": [125.0, 0.0, 1000.0]}},
{"command": "mesh.node.add", "params": {"label": 40, "xyz": [187.5, 0.0, 1000.0]}},
{"command": "mesh.node.add", "params": {"label": 41, "xyz": [250.0, 0.0, 1000.0]}},
{"command": "mesh.node.add", "params": {"label": 42, "xyz": [312.5, 0.0, 1000.0]}},
{"command": "mesh.node.add", "params": {"label": 43, "xyz": [375.0, 0.0, 1000.0]}},
{"command": "mesh.node.add", "params": {"label": 44, "xyz": [437.5, 0.0, 1000.0]}},
{"command": "mesh.node.add", "params": {"label": 45, "xyz": [500.0, 0.0, 1000.0]}},
{"command": "mesh.node.add", "params": {"label": 46, "xyz": [0.0, 65.40312923014305, 997.8589232386034]}},
{"command": "mesh.node.add", "params": {"label": 47, "xyz": [62.5, 65.40312923014305, 997.8589232386034]}},
{"command": "mesh.node.add", "params": {"label": 48, "xyz": [125.0, 65.40312923014305, 997.8589232386034]}},
{"command": "mesh.node.add", "params": {"label": 49, "xyz": [187.5, 65.40312923014305, 997.8589232386034]}},
{"command": "mesh.node.add", "params": {"label": 50, "xyz": [250.0, 65.40312923014305, 997.8589232386034]}},
{"command": "mesh.node.add", "params": {"label": 51, "xyz": [312.5, 65.40312923014305, 997.8589232386034]}},
{"command": "mesh.node.add", "params": {"label": 52, "xyz": [375.0, 65.40312923014305, 997.8589232386034]}},
{"command": "mesh.node.add", "params": {"label": 53, "xyz": [437.5, 65.40312923014305, 997.8589232386034]}},
{"command": "mesh.node.add", "params": {"label": 54, "xyz": [500.0, 65.40312923014305, 997.8589232386034]}},
{"command": "mesh.node.add", "params": {"label": 55, "xyz": [0.0, 130.5261922200516, 991.4448613738103]}},
{"command": "mesh.node.add", "params": {"label": 56, "xyz": [62.5, 130.5261922200516, 991.4448613738103]}},
{"command": "mesh.node.add", "params": {"label": 57, "xyz": [125.0, 130.5261922200516, 991.4448613738103]}},
{"command": "mesh.node.add", "params": {"label": 58, "xyz": [187.5, 130.5261922200516, 991.4448613738103]}},
{"command": "mesh.node.add", "params": {"label": 59, "xyz": [250.0, 130.5261922200516, 991.4448613738103]}},
{"command": "mesh.node.add", "params": {"label": 60, "xyz": [312.5, 130.5261922200516, 991.4448613738103]}},
{"command": "mesh.node.add", "params": {"label": 61, "xyz": [375.0, 130.5261922200516, 991.4448613738103]}},
{"command": "mesh.node.add", "params": {"label": 62, "xyz": [437.5, 130.5261922200516, 991.4448613738103]}},
{"command": "mesh.node.add", "params": {"label": 63, "xyz": [500.0, 130.5261922200516, 991.4448613738103]}},
{"command": "mesh.node.add", "params": {"label": 64, "xyz": [0.0, 195.09032201612825, 980.7852804032304]}},
{"command": "mesh.node.add", "params": {"label": 65, "xyz": [62.5, 195.09032201612825, 980.7852804032304]}},
{"command": "mesh.node.add", "params": {"label": 66, "xyz": [125.0, 195.09032201612825, 980.7852804032304]}},
{"command": "mesh.node.add", "params": {"label": 67, "xyz": [187.5, 195.09032201612825, 980.7852804032304]}},
{"command": "mesh.node.add", "params": {"label": 68, "xyz": [250.0, 195.09032201612825, 980.7852804032304]}},
{"command": "mesh.node.add", "params": {"label": 69, "xyz": [312.5, 195.09032201612825, 980.7852804032304]}},
{"command": "mesh.node.add", "params": {"label": 70, "xyz": [375.0, 195.09032201612825, 980.7852804032304]}},
{"command": "mesh.node.add", "params": {"label": 71, "xyz": [437.5, 195.09032201612825, 980.7852804032304]}},
{"command": "mesh.node.add", "params": {"label": 72, "xyz": [500.0, 195.09032201612825, 980.7852804032304]}},
{"command": "mesh.node.add", "params": {"label": 73, "xyz": [0.0, 258.81904510252076, 965.9258262890684]}},
{"command": "mesh.node.add", "params": {"label": 74, "xyz": [62.5, 258.81904510252076, 965.9258262890684]}},
{"command": "mesh.node.add", "params": {"label": 75, "xyz": [125.0, 258.81904510252076, 965.9258262890684]}},
{"command": "mesh.node.add", "params": {"label": 76, "xyz": [187.5, 258.81904510252076, 965.9258262890684]}},
{"command": "mesh.node.add", "params": {"label": 77, "xyz": [250.0, 258.81904510252076, 965.9258262890684]}},
{"command": "mesh.node.add", "params": {"label": 78, "xyz": [312.5, 258.81904510252076, 965.9258262890684]}},
{"command": "mesh.node.add", "params": {"label": 79, "xyz": [375.0, 258.81904510252076, 965.9258262890684]}},
{"command": "mesh.node.add", "params": {"label": 80, "xyz": [437.5, 258.81904510252076, 965.9258262890684]}},
{"command": "mesh.node.add", "params": {"label": 81, "xyz": [500.0, 258.81904510252076, 965.9258262890684]}},
{"command": "mesh.element.add", "params": {"label": 1, "kind": "quad4", "nodes": [1, 2, 11, 10]}},
{"command": "mesh.element.add", "params": {"label": 2, "kind": "quad4", "nodes": [2, 3, 12, 11]}},
{"command": "mesh.element.add", "params": {"label": 3, "kind": "quad4", "nodes": [3, 4, 13, 12]}},
{"command": "mesh.element.add", "params": {"label": 4, "kind": "quad4", "nodes": [4, 5, 14, 13]}},
{"command": "mesh.element.add", "params": {"label": 5, "kind": "quad4", "nodes": [5, 6, 15, 14]}},
{"command": "mesh.element.add", "params": {"label": 6, "kind": "quad4", "nodes": [6, 7, 16, 15]}},
{"command": "mesh.element.add", "params": {"label": 7, "kind": "quad4", "nodes": [7, 8, 17, 16]}},
{"command": "mesh.element.add", "params": {"label": 8, "kind": "quad4", "nodes": [8, 9, 18, 17]}},
{"command": "mesh.element.add", "params": {"label": 9, "kind": "quad4", "nodes": [10, 11, 20, 19]}},
{"command": "mesh.element.add", "params": {"label": 10, "kind": "quad4", "nodes": [11, 12, 21, 20]}},
{"command": "mesh.element.add", "params": {"label": 11, "kind": "quad4", "nodes": [12, 13, 22, 21]}},
{"command": "mesh.element.add", "params": {"label": 12, "kind": "quad4", "nodes": [13, 14, 23, 22]}},
{"command": "mesh.element.add", "params": {"label": 13, "kind": "quad4", "nodes": [14, 15, 24, 23]}},
{"command": "mesh.element.add", "params": {"label": 14, "kind": "quad4", "nodes": [15, 16, 25, 24]}},
{"command": "mesh.element.add", "params": {"label": 15, "kind": "quad4", "nodes": [16, 17, 26, 25]}},
{"command": "mesh.element.add", "params": {"label": 16, "kind": "quad4", "nodes": [17, 18, 27, 26]}},
{"command": "mesh.element.add", "params": {"label": 17, "kind": "quad4", "nodes": [19, 20, 29, 28]}},
{"command": "mesh.element.add", "params": {"label": 18, "kind": "quad4", "nodes": [20, 21, 30, 29]}},
{"command": "mesh.element.add", "params": {"label": 19, "kind": "quad4", "nodes": [21, 22, 31, 30]}},
{"command": "mesh.element.add", "params": {"label": 20, "kind": "quad4", "nodes": [22, 23, 32, 31]}},
{"command": "mesh.element.add", "params": {"label": 21, "kind": "quad4", "nodes": [23, 24, 33, 32]}},
{"command": "mesh.element.add", "params": {"label": 22, "kind": "quad4", "nodes": [24, 25, 34, 33]}},
{"command": "mesh.element.add", "params": {"label": 23, "kind": "quad4", "nodes": [25, 26, 35, 34]}},
{"command": "mesh.element.add", "params": {"label": 24, "kind": "quad4", "nodes": [26, 27, 36, 35]}},
{"command": "mesh.element.add", "params": {"label": 25, "kind": "quad4", "nodes": [28, 29, 38, 37]}},
{"command": "mesh.element.add", "params": {"label": 26, "kind": "quad4", "nodes": [29, 30, 39, 38]}},
{"command": "mesh.element.add", "params": {"label": 27, "kind": "quad4", "nodes": [30, 31, 40, 39]}},
{"command": "mesh.element.add", "params": {"label": 28, "kind": "quad4", "nodes": [31, 32, 41, 40]}},
{"command": "mesh.element.add", "params": {"label": 29, "kind": "quad4", "nodes": [32, 33, 42, 41]}},
{"command": "mesh.element.add", "params": {"label": 30, "kind": "quad4", "nodes": [33, 34, 43, 42]}},
{"command": "mesh.element.add", "params": {"label": 31, "kind": "quad4", "nodes": [34, 35, 44, 43]}},
{"command": "mesh.element.add", "params": {"label": 32, "kind": "quad4", "nodes": [35, 36, 45, 44]}},
{"command": "mesh.element.add", "params": {"label": 33, "kind": "quad4", "nodes": [37, 38, 47, 46]}},
{"command": "mesh.element.add", "params": {"label": 34, "kind": "quad4", "nodes": [38, 39, 48, 47]}},
{"command": "mesh.element.add", "params": {"label": 35, "kind": "quad4", "nodes": [39, 40, 49, 48]}},
{"command": "mesh.element.add", "params": {"label": 36, "kind": "quad4", "nodes": [40, 41, 50, 49]}},
{"command": "mesh.element.add", "params": {"label": 37, "kind": "quad4", "nodes": [41, 42, 51, 50]}},
{"command": "mesh.element.add", "params": {"label": 38, "kind": "quad4", "nodes": [42, 43, 52, 51]}},
{"command": "mesh.element.add", "params": {"label": 39, "kind": "quad4", "nodes": [43, 44, 53, 52]}},
{"command": "mesh.element.add", "params": {"label": 40, "kind": "quad4", "nodes": [44, 45, 54, 53]}},
{"command": "mesh.element.add", "params": {"label": 41, "kind": "quad4", "nodes": [46, 47, 56, 55]}},
{"command": "mesh.element.add", "params": {"label": 42, "kind": "quad4", "nodes": [47, 48, 57, 56]}},
{"command": "mesh.element.add", "params": {"label": 43, "kind": "quad4", "nodes": [48, 49, 58, 57]}},
{"command": "mesh.element.add", "params": {"label": 44, "kind": "quad4", "nodes": [49, 50, 59, 58]}},
{"command": "mesh.element.add", "params": {"label": 45, "kind": "quad4", "nodes": [50, 51, 60, 59]}},
{"command": "mesh.element.add", "params": {"label": 46, "kind": "quad4", "nodes": [51, 52, 61, 60]}},
{"command": "mesh.element.add", "params": {"label": 47, "kind": "quad4", "nodes": [52, 53, 62, 61]}},
{"command": "mesh.element.add", "params": {"label": 48, "kind": "quad4", "nodes": [53, 54, 63, 62]}},
{"command": "mesh.element.add", "params": {"label": 49, "kind": "quad4", "nodes": [55, 56, 65, 64]}},
{"command": "mesh.element.add", "params": {"label": 50, "kind": "quad4", "nodes": [56, 57, 66, 65]}},
{"command": "mesh.element.add", "params": {"label": 51, "kind": "quad4", "nodes": [57, 58, 67, 66]}},
{"command": "mesh.element.add", "params": {"label": 52, "kind": "quad4", "nodes": [58, 59, 68, 67]}},
{"command": "mesh.element.add", "params": {"label": 53, "kind": "quad4", "nodes": [59, 60, 69, 68]}},
{"command": "mesh.element.add", "params": {"label": 54, "kind": "quad4", "nodes": [60, 61, 70, 69]}},
{"command": "mesh.element.add", "params": {"label": 55, "kind": "quad4", "nodes": [61, 62, 71, 70]}},
{"command": "mesh.element.add", "params": {"label": 56, "kind": "quad4", "nodes": [62, 63, 72, 71]}},
{"command": "mesh.element.add", "params": {"label": 57, "kind": "quad4", "nodes": [64, 65, 74, 73]}},
{"command": "mesh.element.add", "params": {"label": 58, "kind": "quad4", "nodes": [65, 66, 75, 74]}},
{"command": "mesh.element.add", "params": {"label": 59, "kind": "quad4", "nodes": [66, 67, 76, 75]}},
{"command": "mesh.element.add", "params": {"label": 60, "kind": "quad4", "nodes": [67, 68, 77, 76]}},
{"command": "mesh.element.add", "params": {"label": 61, "kind": "quad4", "nodes": [68, 69, 78, 77]}},
{"command": "mesh.element.add", "params": {"label": 62, "kind": "quad4", "nodes": [69, 70, 79, 78]}},
{"command": "mesh.element.add", "params": {"label": 63, "kind": "quad4", "nodes": [70, 71, 80, 79]}},
{"command": "mesh.element.add", "params": {"label": 64, "kind": "quad4", "nodes": [71, 72, 81, 80]}},
{"command": "model.set.create", "params": {"name": "patch", "elements": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64]}},
{"command": "model.set.create", "params": {"name": "inside", "faces": [{"element": 1, "face": 2}, {"element": 2, "face": 2}, {"element": 3, "face": 2}, {"element": 4, "face": 2}, {"element": 5, "face": 2}, {"element": 6, "face": 2}, {"element": 7, "face": 2}, {"element": 8, "face": 2}, {"element": 9, "face": 2}, {"element": 10, "face": 2}, {"element": 11, "face": 2}, {"element": 12, "face": 2}, {"element": 13, "face": 2}, {"element": 14, "face": 2}, {"element": 15, "face": 2}, {"element": 16, "face": 2}, {"element": 17, "face": 2}, {"element": 18, "face": 2}, {"element": 19, "face": 2}, {"element": 20, "face": 2}, {"element": 21, "face": 2}, {"element": 22, "face": 2}, {"element": 23, "face": 2}, {"element": 24, "face": 2}, {"element": 25, "face": 2}, {"element": 26, "face": 2}, {"element": 27, "face": 2}, {"element": 28, "face": 2}, {"element": 29, "face": 2}, {"element": 30, "face": 2}, {"element": 31, "face": 2}, {"element": 32, "face": 2}, {"element": 33, "face": 2}, {"element": 34, "face": 2}, {"element": 35, "face": 2}, {"element": 36, "face": 2}, {"element": 37, "face": 2}, {"element": 38, "face": 2}, {"element": 39, "face": 2}, {"element": 40, "face": 2}, {"element": 41, "face": 2}, {"element": 42, "face": 2}, {"element": 43, "face": 2}, {"element": 44, "face": 2}, {"element": 45, "face": 2}, {"element": 46, "face": 2}, {"element": 47, "face": 2}, {"element": 48, "face": 2}, {"element": 49, "face": 2}, {"element": 50, "face": 2}, {"element": 51, "face": 2}, {"element": 52, "face": 2}, {"element": 53, "face": 2}, {"element": 54, "face": 2}, {"element": 55, "face": 2}, {"element": 56, "face": 2}, {"element": 57, "face": 2}, {"element": 58, "face": 2}, {"element": 59, "face": 2}, {"element": 60, "face": 2}, {"element": 61, "face": 2}, {"element": 62, "face": 2}, {"element": 63, "face": 2}, {"element": 64, "face": 2}]}},
{"command": "model.set.create", "params": {"name": "AB", "nodes": [1, 2, 3, 4, 5, 6, 7, 8, 9]}},
{"command": "model.set.create", "params": {"name": "AD_BC", "nodes": [1, 9, 10, 18, 19, 27, 28, 36, 37, 45, 46, 54, 55, 63, 64, 72, 73, 81]}},
{"command": "model.set.create", "params": {"name": "DC_ends", "nodes": [73, 81]}},
{"command": "model.set.create", "params": {"name": "DC_inner", "nodes": [74, 75, 76, 77, 78, 79, 80]}},
{"command": "model.material.create", "params": {"name": "Steel", "elastic": {"type": "isotropic", "young": 210000.0, "poisson": 0.3}}},
{"command": "model.section.create", "params": {"name": "patch", "set": "patch", "material": "Steel", "kind": {"type": "shell", "thickness": 10.0}}},
{"command": "model.boundary_condition.create", "params": {"name": "AB", "type": "displacement", "target": {"set": "AB"}, "values": [0, 0, 0, 0, 0, 0]}},
{"command": "model.boundary_condition.create", "params": {"name": "AD_BC", "type": "symmetry", "target": {"set": "AD_BC"}, "plane": "x"}},
{"command": "model.load.create", "params": {"name": "moment_ends", "type": "moment", "target": {"set": "DC_ends"}, "vector": [-31250.0, 0, 0], "distribution": "per_node"}},
{"command": "model.load.create", "params": {"name": "moment_inner", "type": "moment", "target": {"set": "DC_inner"}, "vector": [-62500.0, 0, 0], "distribution": "per_node"}},
{"command": "model.load.create", "params": {"name": "pressure", "type": "pressure", "set": "inside", "magnitude": 0.6}},
{"command": "model.load.create", "params": {"name": "pull_ends", "type": "force", "target": {"set": "DC_ends"}, "vector": [0.0, 18111.109242920033, -4852.857095672264], "distribution": "per_node"}},
{"command": "model.load.create", "params": {"name": "pull_inner", "type": "force", "target": {"set": "DC_inner"}, "vector": [0.0, 36222.218485840065, -9705.714191344528], "distribution": "per_node"}},
{"command": "model.step.create", "params": {"name": "Moment", "procedure": {"type": "static"}, "boundary_conditions": ["AB", "AD_BC"], "loads": ["moment_ends", "moment_inner"]}},
{"command": "model.step.create", "params": {"name": "Pressure", "procedure": {"type": "static"}, "boundary_conditions": ["AB", "AD_BC"], "loads": ["pressure", "pull_ends", "pull_inner"]}},
{"command": "solve.run", "params": {}, "id": "solve"}
]
+108
View File
@@ -0,0 +1,108 @@
# NAFEMS LE3 hemispherical shell with point loads (MITC4 shells)
Our model of the published problem: a quarter of a closed hemisphere of mid-surface radius 10 m
and thickness 0.04 m, E = 68.25 GPa, ν = 0.3 (m, kN, kPa: E = 6.825 × 10⁷), centred at the origin
with its pole E on +z. A radial force of 2 kN pulls outward at A (10, 0, 0) on the equator and one
of 2 kN pushes inward at C (0, 10, 0). The edge AE lies on the plane y = 0 and CE on x = 0, both
planes of symmetry; u_z = 0 at E holds the shell vertically; the equator is free. The target is
the radial displacement at A, u_x there.
(LE3 has no hole at the pole, unlike the MacNeal–Harder hemisphere of
`pinched-hemisphere`, which has an 18° hole and different loads.)
## The mesh
The surface is doubly curved, so the recipe writes its nodes and elements one by one with
`mesh.node.add` and `mesh.element.add` ([docs/verification.md](../../../docs/verification.md)).
Meridians every 90°/32 of longitude λ and parallels every 90°/32 of latitude β divide the quarter
into cells; node 1 + i + 33 j lies at R (cos β_j cos λ_i, cos β_j sin λ_i, sin β_j), and the pole is
node 1057. Every cell below the top parallel is a quad4 whose four corners lie on two parallels,
symmetric about its mid-meridian, so it is planar (flat shells model a curved surface best with
flat facets); the 32 cells at the pole are tri3 (MITC3) triangles that share the pole node. All
elements run counter-clockwise seen from outside.
A first mesh projected three faces of a cube onto the sphere (no pole, all quad4), but its cells
are warped, and the flat MITC4 element on warped cells was far too stiff (0.147 at 32 × 32 per
face). The longitude–latitude mesh converges as the table shows.
The recipe was generated with this script (`python3 le3.py 32 > recipe.json`):
```python
import json, math, sys
def le3(n):
"""NAFEMS LE3 hemisphere quadrant (m, kN, kPa): R = 10, x ≥ 0, y ≥ 0, z ≥ 0, pole E on +z.
Meridians every 90°/n of longitude λ, parallels every 90°/n of latitude β; the cells are planar
trapezoids and the ring at the pole is triangles."""
R = 10.0
label = lambda i, j: 1 + i + (n + 1) * j # longitude i, latitude j < n
pole = 1 + (n + 1) * n
commands = []
for j in range(n):
for i in range(n + 1):
lam, beta = math.pi / 2 * i / n, math.pi / 2 * j / n
xyz = [R * math.cos(beta) * math.cos(lam), R * math.cos(beta) * math.sin(lam), R * math.sin(beta)]
commands.append({"command": "mesh.node.add", "params": {"label": label(i, j), "xyz": xyz}})
commands.append({"command": "mesh.node.add", "params": {"label": pole, "xyz": [0.0, 0.0, R]}})
k = 0
for j in range(n):
for i in range(n):
k += 1
if j < n - 1:
kind, nodes = "quad4", [label(i, j), label(i + 1, j), label(i + 1, j + 1), label(i, j + 1)]
else:
kind, nodes = "tri3", [label(i, j), label(i + 1, j), pole]
commands.append({"command": "mesh.element.add", "params": {"label": k, "kind": kind, "nodes": nodes}})
A, C = label(0, 0), label(n, 0)
return commands + [
{"command": "model.set.create", "params": {"name": "shell", "elements": list(range(1, k + 1))}},
{"command": "model.set.create", "params": {"name": "AE", "nodes": [label(0, j) for j in range(n)] + [pole]}},
{"command": "model.set.create", "params": {"name": "CE", "nodes": [label(n, j) for j in range(n)] + [pole]}},
{"command": "model.set.create", "params": {"name": "A", "nodes": [A]}},
{"command": "model.set.create", "params": {"name": "C", "nodes": [C]}},
{"command": "model.set.create", "params": {"name": "E", "nodes": [pole]}},
{"command": "model.material.create", "params": {"name": "Alloy", "elastic": {"type": "isotropic", "young": 6.825e7, "poisson": 0.3}}},
{"command": "model.section.create", "params": {"name": "shell", "set": "shell", "material": "Alloy", "kind": {"type": "shell", "thickness": 0.04}}},
{"command": "model.boundary_condition.create", "params": {"name": "AE", "type": "symmetry", "target": {"set": "AE"}, "plane": "y"}},
{"command": "model.boundary_condition.create", "params": {"name": "CE", "type": "symmetry", "target": {"set": "CE"}, "plane": "x"}},
{"command": "model.boundary_condition.create", "params": {"name": "E", "type": "displacement", "target": {"set": "E"}, "values": [None, None, 0, None, None, None]}},
{"command": "model.load.create", "params": {"name": "pull_A", "type": "force", "target": {"set": "A"}, "vector": [2.0, 0, 0]}},
{"command": "model.load.create", "params": {"name": "push_C", "type": "force", "target": {"set": "C"}, "vector": [0, -2.0, 0]}},
{"command": "model.step.create", "params": {"name": "Load", "procedure": {"type": "static"}, "boundary_conditions": ["AE", "CE", "E"], "loads": ["pull_A", "push_C"]}},
{"command": "solve.static", "params": {}, "id": "solve"},
]
n = int(sys.argv[1]) if len(sys.argv) > 1 else 32
print("[\n " + ",\n ".join(json.dumps(c) for c in le3(n)) + "\n]")
```
## Refinement
Every row is the listing above with the row's divisions, solved and probed at A as the case does;
the case mesh is 32 × 32:
| cells | u_x at A (m) | error |
|---|---|---|
| 8 × 8 | 0.18197 | −1.64% |
| 16 × 16 | 0.18324 | −0.95% |
| 32 × 32 (this case) | 0.18418 | −0.44% |
| 64 × 64 | 0.18469 | −0.17% |
The result rises with every refinement and its increments shrink, but slowly: under a point load,
a shell with transverse shear flexibility has no finite limit, only a very slow (logarithmic)
growth (the ESRD guide makes the same remark for its own p-version solution, 0.1844). The next
refinement moves the case value by 0.28%, a seventh of the tolerance.
## Tolerance
2%: the target is published to three figures and the displacement under a point load is not a
converging quantity in the strict sense (above); 32 × 32 is within 0.44%. `fixture.json` holds the
32 × 32 value: the mesh is written by the recipe, so it is bit-identical on every platform.
## Running it
```sh
cargo xtask oracle --case nafems-le3
cargo xtask oracle --bless nafems-le3 # only after the case passes
```
+15
View File
@@ -0,0 +1,15 @@
{
"title": "NAFEMS LE3 hemispherical shell with point loads (MITC4 shells)",
"kind": "benchmark",
"source": "NAFEMS, The Standard NAFEMS Benchmarks, TNSB Rev. 3, October 1990, test LE3 (target: radial displacement 0.185 m at A); the problem and target as reproduced in the Abaqus 2016 Benchmarks Guide, section 4.2.3 'LE3: Hemispherical shell with point loads', and in the ESRD StressCheck Benchmarks Guide 'The Standard NAFEMS Benchmarks: Linear Elastic Tests' (2018), p. 12",
"quantities": [
{
"id": "ur_A",
"quantity": "radial displacement at A (10, 0, 0), m",
"measure": {"command": "solve.probe", "params": {"step": "Load", "at": [[10, 0, 0]]}, "pointer": "/nodes/0/displacement/0"},
"value": 0.185,
"tolerance": {"relative": 0.02},
"reason": "the target is published to three figures; the displacement under a point load on a shear-deformable shell keeps growing slowly as the mesh is refined (32 × 32 is 0.44% low, 64 × 64 0.16%; see README)"
}
]
}
+9
View File
@@ -0,0 +1,9 @@
{
"note": "FieldCraft's accepted results for this case, written by `cargo xtask oracle --bless`. A result outside its band fails CI.",
"values": {
"ur_A": {
"band": 1.85e-10,
"value": 0.18417526380456614
}
}
}
File diff suppressed because it is too large. Load diff
+100
View File
@@ -0,0 +1,100 @@
# NAFEMS LE5 Z-section cantilever (hex20)
Our model of the published problem: a cantilever 10 m long (x) whose cross-section is a Z of
0.1 m thick walls, E = 210 GPa, ν = 0.3 (m, MN, MPa). On the mid-surface the web is 2 m wide, in
the plane z = 0 from y = −1 to y = 1, and the two flanges are 1 m high: the upper one at y = 1
rises to z = 1, the lower one at y = −1 drops to z = −1. Every displacement is zero at x = 0. A
torque of 1.2 MN·m acts at x = 10 as two shear forces of 0.6 MN, one along each flange's end
(+z on the upper flange, −z on the lower), 2 m apart. The target is the axial stress σ_xx at A,
x = 2.5 on the mid-surface of the lower flange at its free edge (2.5, −1, −1), where the restrained
warping of the section puts its largest axial stress.
## The mesh
The walls are solid hex20 blocks (`mesh.block`), the route LE1 and LE10 take: the issue allows
quad4 shells or hex20, and the shell model reaches a different limit (below). Five blocks,
2 elements through every wall: the two flanges (0.95 m outside the web, k = 4 elements), the two
corner blocks where a flange meets the web (2 × 2), and the web between them (8 elements); 32
elements along the length. The blocks share their faces, so `mesh.block` joins them. The shear
force on each flange is a total over the flange block's end face (z from ±0.05 to ±1), split by
area. A is a node: the mid-plane of the lower flange (y = −1) is a node plane of its 2 elements
through the thickness.
The recipe was generated with this script (`python3 le5.py 4 > recipe.json`):
```python
import json, sys
def le5_solid(k):
"""NAFEMS LE5 Z-section as hex20 solid blocks (m, MN, MPa): mid-surface web 2 wide in z = 0,
flanges at y = ±1 reaching z = ±1, all walls 0.1 thick, length 10 along x; k sets the density
(8k cells along the length, k per 0.95 m across the walls, 2 through the thickness)."""
h = 0.05
blocks = { # name: (y0, y1, z0, z1, ny, nz)
"lower_flange": (-1 - h, -1 + h, -1.0, -h, 2, k),
"lower_corner": (-1 - h, -1 + h, -h, h, 2, 2),
"web": (-1 + h, 1 - h, -h, h, 2 * k, 2),
"upper_corner": (1 - h, 1 + h, -h, h, 2, 2),
"upper_flange": (1 - h, 1 + h, h, 1.0, 2, k),
}
commands = []
for name, (y0, y1, z0, z1, ny, nz) in blocks.items():
base = [[0.0, y0, z0], [0.0, y1, z0], [0.0, y1, z1], [0.0, y0, z1]]
corners = base + [[10.0, y, z] for _, y, z in base]
commands.append({"command": "mesh.block", "params": {"body": name, "kind": "hex20", "corners": corners, "divisions": [ny, nz, 8 * k], "faces": {"1": "root", "2": "tip"}}})
return commands + [
{"command": "model.set.create", "params": {"name": "all", "geometry": [{"body": b} for b in blocks]}},
{"command": "model.set.create", "params": {"name": "root", "geometry": [{"body": b, "face": "root"} for b in blocks]}},
{"command": "model.set.create", "params": {"name": "lower_tip", "geometry": [{"body": "lower_flange", "face": "tip"}]}},
{"command": "model.set.create", "params": {"name": "upper_tip", "geometry": [{"body": "upper_flange", "face": "tip"}]}},
{"command": "model.material.create", "params": {"name": "Steel", "elastic": {"type": "isotropic", "young": 210000.0, "poisson": 0.3}}},
{"command": "model.section.create", "params": {"name": "z_section", "set": "all", "material": "Steel", "kind": {"type": "solid"}}},
{"command": "model.boundary_condition.create", "params": {"name": "root", "type": "fixed", "target": {"set": "root"}}},
# the 1.2 MN·m torque: 0.6 MN along each flange's end face, 2 m apart
{"command": "model.load.create", "params": {"name": "lower_shear", "type": "force", "target": {"set": "lower_tip"}, "vector": [0, 0, -0.6]}},
{"command": "model.load.create", "params": {"name": "upper_shear", "type": "force", "target": {"set": "upper_tip"}, "vector": [0, 0, 0.6]}},
{"command": "model.step.create", "params": {"name": "Torque", "procedure": {"type": "static"}, "boundary_conditions": ["root"], "loads": ["lower_shear", "upper_shear"]}},
{"command": "solve.static", "params": {}, "id": "solve"},
]
k = int(sys.argv[1]) if len(sys.argv) > 1 else 4
print("[\n " + ",\n ".join(json.dumps(c) for c in le5_solid(k)) + "\n]")
```
## Refinement
Every row is the listing above with the row's k, solved and probed at A as the case does; the
case mesh is k = 4:
| k | elements | σ_xx at A | error |
|---|---|---|---|
| 1 | 128 | −108.93 | +0.86% |
| 2 | 384 | −109.27 | +1.17% |
| 4 (this case) | 1280 | −109.46 | +1.35% |
| 8 | 4608 | −109.60 | +1.48% |
The result converges to about −109.7, 1.6% beyond the target. The published value comes from a
thin-walled idealisation of the section; a solid model of the same section differs from it by
about that much (ESRD's guide reports −109.5 for its converged p-version solid model on two
meshes, p. 16). The next refinement of the case mesh moves the value by 0.13%, under a tenth of
the tolerance.
A model of quad4 MITC4 shells on the mid-surface (walls meeting at the junction lines, the edge
shears as consistent nodal forces) converges further away, to about −111.3 (320 elements along the
length and 16 per metre across the walls: −111.65 and −111.06 at A from the two elements that meet
there), 3.1% beyond the target (the Abaqus Benchmarks Guide, Table 4.2.5–1, reports −110.38 for its
S4 shell on the refined mesh); we have not separated how much of the difference is the shell
idealisation of the junctions. That is why this case is the solid model.
## Tolerance
2%: the target is published to three figures, and the solid model's converged value differs from
the thin-walled reference by about 1.5% (above). `fixture.json` holds the k = 4 value: the mesh is
built by `mesh.block` from the recipe's corners, so it is the same on every platform.
## Running it
```sh
cargo xtask oracle --case nafems-le5
cargo xtask oracle --bless nafems-le5 # only after the case passes
```
+15
View File
@@ -0,0 +1,15 @@
{
"title": "NAFEMS LE5 Z-section cantilever (hex20)",
"kind": "benchmark",
"source": "NAFEMS, The Standard NAFEMS Benchmarks, TNSB Rev. 3, October 1990, test LE5 (target: axial stress −108 MPa at A, mid-surface); the problem and target as reproduced in the Abaqus 2016 Benchmarks Guide, section 4.2.5 'LE5: Z-section cantilever', and in the ESRD StressCheck Benchmarks Guide 'The Standard NAFEMS Benchmarks: Linear Elastic Tests' (2018), p. 15",
"quantities": [
{
"id": "sxx_A",
"quantity": "σ_xx at A (2.5, −1, −1), mid-surface of the lower flange at its free edge",
"measure": {"command": "solve.probe", "params": {"step": "Torque", "at": [[2.5, -1, -1]]}, "pointer": "/nodes/0/stress/0"},
"value": -108.0,
"tolerance": {"relative": 0.02},
"reason": "the target is published to three figures and comes from a thin-walled idealisation; a 3D solid model of the section converges about 1.5% beyond it (ours: −109.46 here, −109.60 on the next mesh; ESRD's p-version solid: −109.5, see README)"
}
]
}
+9
View File
@@ -0,0 +1,9 @@
{
"note": "FieldCraft's accepted results for this case, written by `cargo xtask oracle --bless`. A result outside its band fails CI.",
"values": {
"sxx_A": {
"band": 1.0945472240551884e-7,
"value": -109.45472240551884
}
}
}
+18
View File
@@ -0,0 +1,18 @@
[
{"command": "mesh.block", "params": {"body": "lower_flange", "kind": "hex20", "corners": [[0.0, -1.05, -1.0], [0.0, -0.95, -1.0], [0.0, -0.95, -0.05], [0.0, -1.05, -0.05], [10.0, -1.05, -1.0], [10.0, -0.95, -1.0], [10.0, -0.95, -0.05], [10.0, -1.05, -0.05]], "divisions": [2, 4, 32], "faces": {"1": "root", "2": "tip"}}},
{"command": "mesh.block", "params": {"body": "lower_corner", "kind": "hex20", "corners": [[0.0, -1.05, -0.05], [0.0, -0.95, -0.05], [0.0, -0.95, 0.05], [0.0, -1.05, 0.05], [10.0, -1.05, -0.05], [10.0, -0.95, -0.05], [10.0, -0.95, 0.05], [10.0, -1.05, 0.05]], "divisions": [2, 2, 32], "faces": {"1": "root", "2": "tip"}}},
{"command": "mesh.block", "params": {"body": "web", "kind": "hex20", "corners": [[0.0, -0.95, -0.05], [0.0, 0.95, -0.05], [0.0, 0.95, 0.05], [0.0, -0.95, 0.05], [10.0, -0.95, -0.05], [10.0, 0.95, -0.05], [10.0, 0.95, 0.05], [10.0, -0.95, 0.05]], "divisions": [8, 2, 32], "faces": {"1": "root", "2": "tip"}}},
{"command": "mesh.block", "params": {"body": "upper_corner", "kind": "hex20", "corners": [[0.0, 0.95, -0.05], [0.0, 1.05, -0.05], [0.0, 1.05, 0.05], [0.0, 0.95, 0.05], [10.0, 0.95, -0.05], [10.0, 1.05, -0.05], [10.0, 1.05, 0.05], [10.0, 0.95, 0.05]], "divisions": [2, 2, 32], "faces": {"1": "root", "2": "tip"}}},
{"command": "mesh.block", "params": {"body": "upper_flange", "kind": "hex20", "corners": [[0.0, 0.95, 0.05], [0.0, 1.05, 0.05], [0.0, 1.05, 1.0], [0.0, 0.95, 1.0], [10.0, 0.95, 0.05], [10.0, 1.05, 0.05], [10.0, 1.05, 1.0], [10.0, 0.95, 1.0]], "divisions": [2, 4, 32], "faces": {"1": "root", "2": "tip"}}},
{"command": "model.set.create", "params": {"name": "all", "geometry": [{"body": "lower_flange"}, {"body": "lower_corner"}, {"body": "web"}, {"body": "upper_corner"}, {"body": "upper_flange"}]}},
{"command": "model.set.create", "params": {"name": "root", "geometry": [{"body": "lower_flange", "face": "root"}, {"body": "lower_corner", "face": "root"}, {"body": "web", "face": "root"}, {"body": "upper_corner", "face": "root"}, {"body": "upper_flange", "face": "root"}]}},
{"command": "model.set.create", "params": {"name": "lower_tip", "geometry": [{"body": "lower_flange", "face": "tip"}]}},
{"command": "model.set.create", "params": {"name": "upper_tip", "geometry": [{"body": "upper_flange", "face": "tip"}]}},
{"command": "model.material.create", "params": {"name": "Steel", "elastic": {"type": "isotropic", "young": 210000.0, "poisson": 0.3}}},
{"command": "model.section.create", "params": {"name": "z_section", "set": "all", "material": "Steel", "kind": {"type": "solid"}}},
{"command": "model.boundary_condition.create", "params": {"name": "root", "type": "fixed", "target": {"set": "root"}}},
{"command": "model.load.create", "params": {"name": "lower_shear", "type": "force", "target": {"set": "lower_tip"}, "vector": [0, 0, -0.6]}},
{"command": "model.load.create", "params": {"name": "upper_shear", "type": "force", "target": {"set": "upper_tip"}, "vector": [0, 0, 0.6]}},
{"command": "model.step.create", "params": {"name": "Torque", "procedure": {"type": "static"}, "boundary_conditions": ["root"], "loads": ["lower_shear", "upper_shear"]}},
{"command": "solve.static", "params": {}, "id": "solve"}
]
+94
View File
@@ -0,0 +1,94 @@
# NAFEMS LE7 axisymmetric cylinder/sphere under pressure (axisymmetric quad8)
Our model of the published problem: a thin-walled vessel, a cylinder closed by a hemispherical
cap, of mid-surface radius 1 m and wall thickness 0.025 m, E = 210 GPa, ν = 0.3 (m, MN, MPa),
under an internal pressure of 1 MPa. In FieldCraft's axisymmetric models x is the radius and y the
axis. The cylinder runs from F (y = 0) to the junction C (y = 1.5), the cap from C to the pole A
(y = 2.5). The axial displacement is zero at F (the vessel's plane of symmetry: the whole bottom
edge) and the radial one at A (the pole edge, on the axis). The target is the axial stress σ_yy
on the outer surface at D (R = 1.0125, y = 1.4034), 0.0966 m below the junction, inside the
bending boundary layer the cap's different stiffness causes.
## The mesh
Four `mesh.block` quad8 patches through the wall thickness: three cylinder blocks with E
(y = 1.1136) and D as block corners, so D is a node, and the cap, a quarter ring whose two curved
edges are arcs about (0, 1.5) and whose fourth edge lies on the axis. 2 elements through the
wall, about 50 per metre along it (56, 14 and 5 in the cylinder blocks, 79 round the cap); the
blocks share their edges, so `mesh.block` joins them. The pressure acts on the inner edges of all
four blocks.
The recipe was generated with this script (`python3 le7.py 2 50 > recipe.json`):
```python
import json, math, sys
def le7(nt, per_m):
"""NAFEMS LE7 cylinder with a hemispherical cap, axisymmetric (m, MN, MPa): x is the radius,
y the axis. Mid-surface radius 1, wall 0.025 thick; the cylinder runs from F (y = 0, the plane
of symmetry) to C (y = 1.5), the cap from C to the pole A (y = 2.5). nt elements through the
wall, about per_m elements per metre along it."""
ri, ro = 1 - 0.0125, 1 + 0.0125
cuts = [0.0, 1.1136, 1.4034, 1.5] # F, E, D, C: E and D are nodes
along = lambda length: max(1, round(length * per_m))
commands = []
for k, (y0, y1) in enumerate(zip(cuts, cuts[1:])):
commands.append({"command": "mesh.block", "params": {"body": f"cylinder_{k + 1}", "kind": "quad8",
"corners": [[ri, y0, 0], [ro, y0, 0], [ro, y1, 0], [ri, y1, 0]], "divisions": [nt, along(y1 - y0)],
"edges": [{"corners": [1, 4], "name": "inside"}, {"corners": [1, 2], "name": "bottom"}]}})
commands.append({"command": "mesh.block", "params": {"body": "cap", "kind": "quad8",
"corners": [[ri, 1.5, 0], [ro, 1.5, 0], [0, 1.5 + ro, 0], [0, 1.5 + ri, 0]], "divisions": [nt, along(math.pi / 2)],
"edges": [{"corners": [2, 3], "arc": {"center": [0, 1.5, 0]}}, {"corners": [4, 1], "arc": {"center": [0, 1.5, 0]}, "name": "inside"},
{"corners": [3, 4], "name": "pole"}]}})
bodies = ["cylinder_1", "cylinder_2", "cylinder_3", "cap"]
return commands + [
{"command": "model.set.create", "params": {"name": "all", "geometry": [{"body": b, "face": "area"} for b in bodies]}},
{"command": "model.set.create", "params": {"name": "inside", "geometry": [{"body": b, "edge": "inside"} for b in bodies]}},
{"command": "model.set.create", "params": {"name": "F", "geometry": [{"body": "cylinder_1", "edge": "bottom"}]}},
{"command": "model.set.create", "params": {"name": "A", "geometry": [{"body": "cap", "edge": "pole"}]}},
{"command": "model.material.create", "params": {"name": "Steel", "elastic": {"type": "isotropic", "young": 210000.0, "poisson": 0.3}}},
{"command": "model.section.create", "params": {"name": "vessel", "set": "all", "material": "Steel", "kind": {"type": "axisymmetric"}}},
{"command": "model.boundary_condition.create", "params": {"name": "F", "type": "symmetry", "target": {"set": "F"}, "plane": "y"}},
{"command": "model.boundary_condition.create", "params": {"name": "A", "type": "symmetry", "target": {"set": "A"}, "plane": "x"}},
{"command": "model.load.create", "params": {"name": "pressure", "type": "pressure", "set": "inside", "magnitude": 1.0}},
{"command": "model.step.create", "params": {"name": "Pressure", "procedure": {"type": "static"}, "boundary_conditions": ["F", "A"], "loads": ["pressure"]}},
{"command": "solve.static", "params": {}, "id": "solve"},
]
nt, per_m = (int(a) for a in sys.argv[1:3]) if len(sys.argv) > 2 else (2, 50)
print("[\n " + ",\n ".join(json.dumps(c) for c in le7(nt, per_m)) + "\n]")
```
## Refinement
Every row is the listing above with the row's divisions, solved and probed at D as the case does;
the case mesh is 2 × 50:
| through the wall × per metre | σ_yy at D | against 25.86 | against 25.27 |
|---|---|---|---|
| 2 × 25 | 25.331 | −2.05% | +0.24% |
| 2 × 50 (this case) | 25.283 | −2.23% | +0.05% |
| 4 × 100 | 25.271 | −2.28% | +0.00% |
| 8 × 200 | 25.269 | −2.29% | −0.00% |
Far from the junction the model is exact: at F the axial stress on the outer surface is 19.503
(p r_i²/(r_o² − r_i²)) and the hoop stress 39.006 (Lamé).
## Two references, one known failure
The published target, 25.86, comes from axisymmetric thin-shell theory. The ESRD guide solves the
same problem with axisymmetric solid elements and reports 25.27 on two meshes, 2.3% below the
target, and our solid model converges to the same 25.27: the difference is the model, not the
mesh. So the case keeps the NAFEMS target with the
tolerance a shell model would earn, 1%, marked `known_failure` until FieldCraft has an
axisymmetric shell element, and checks the solid model against the solid reference, also at 1%.
`fixture.json` holds the passing quantity's 2 × 50 value: the mesh is built by `mesh.block` from
the recipe's corners, so it is the same on every platform.
## Running it
```sh
cargo xtask oracle --case nafems-le7
cargo xtask oracle --bless nafems-le7 # only after the case passes
```
+24
View File
@@ -0,0 +1,24 @@
{
"title": "NAFEMS LE7 axisymmetric cylinder/sphere under pressure (axisymmetric quad8)",
"kind": "benchmark",
"source": "NAFEMS, The Standard NAFEMS Benchmarks, TNSB Rev. 3, October 1990, test LE7 (target: axial stress 25.86 MPa on the outer surface at D, R = 1.0125, Z = 1.4034), as reproduced in the ESRD StressCheck Benchmarks Guide 'The Standard NAFEMS Benchmarks: Linear Elastic Tests' (2018), p. 23, which also gives its converged axisymmetric solid result, 25.27 MPa (p. 24); the Abaqus 2016 Benchmarks Guide, section 4.2.7 'LE7: Axisymmetric cylinder/sphere under pressure', gives the target rounded to 25.9 MPa",
"quantities": [
{
"id": "syy_D",
"quantity": "axial stress σ_yy at D (1.0125, 1.4034), outer surface",
"measure": {"command": "solve.probe", "params": {"step": "Pressure", "at": [[1.0125, 1.4034, 0]]}, "pointer": "/nodes/0/stress/1"},
"value": 25.86,
"tolerance": {"relative": 0.01},
"reason": "the target is published to four figures for the thin-shell model of the vessel, and a model of the same theory converges well within 1%",
"known_failure": "the target is the axisymmetric thin-shell solution and FieldCraft has no axisymmetric shell element; our axisymmetric solid model converges to 25.27 (−2.3%), the value ESRD reports for its converged axisymmetric solid (quantity syy_D_solid)"
},
{
"id": "syy_D_solid",
"quantity": "axial stress σ_yy at D (1.0125, 1.4034), outer surface, against the converged solid model",
"measure": {"command": "solve.probe", "params": {"step": "Pressure", "at": [[1.0125, 1.4034, 0]]}, "pointer": "/nodes/0/stress/1"},
"value": 25.27,
"tolerance": {"relative": 0.01},
"reason": "the same model idealisation (axisymmetric solid) as the reference, which ESRD reports converged to under 1% on two meshes; ours moves by 0.05% on the next refinement (see README)"
}
]
}
+9
View File
@@ -0,0 +1,9 @@
{
"note": "FieldCraft's accepted results for this case, written by `cargo xtask oracle --bless`. A result outside its band fails CI.",
"values": {
"syy_D_solid": {
"band": 2.5282545696283336e-8,
"value": 25.282545696283336
}
}
}
+17
View File
@@ -0,0 +1,17 @@
[
{"command": "mesh.block", "params": {"body": "cylinder_1", "kind": "quad8", "corners": [[0.9875, 0.0, 0], [1.0125, 0.0, 0], [1.0125, 1.1136, 0], [0.9875, 1.1136, 0]], "divisions": [2, 56], "edges": [{"corners": [1, 4], "name": "inside"}, {"corners": [1, 2], "name": "bottom"}]}},
{"command": "mesh.block", "params": {"body": "cylinder_2", "kind": "quad8", "corners": [[0.9875, 1.1136, 0], [1.0125, 1.1136, 0], [1.0125, 1.4034, 0], [0.9875, 1.4034, 0]], "divisions": [2, 14], "edges": [{"corners": [1, 4], "name": "inside"}, {"corners": [1, 2], "name": "bottom"}]}},
{"command": "mesh.block", "params": {"body": "cylinder_3", "kind": "quad8", "corners": [[0.9875, 1.4034, 0], [1.0125, 1.4034, 0], [1.0125, 1.5, 0], [0.9875, 1.5, 0]], "divisions": [2, 5], "edges": [{"corners": [1, 4], "name": "inside"}, {"corners": [1, 2], "name": "bottom"}]}},
{"command": "mesh.block", "params": {"body": "cap", "kind": "quad8", "corners": [[0.9875, 1.5, 0], [1.0125, 1.5, 0], [0, 2.5125, 0], [0, 2.4875, 0]], "divisions": [2, 79], "edges": [{"corners": [2, 3], "arc": {"center": [0, 1.5, 0]}}, {"corners": [4, 1], "arc": {"center": [0, 1.5, 0]}, "name": "inside"}, {"corners": [3, 4], "name": "pole"}]}},
{"command": "model.set.create", "params": {"name": "all", "geometry": [{"body": "cylinder_1", "face": "area"}, {"body": "cylinder_2", "face": "area"}, {"body": "cylinder_3", "face": "area"}, {"body": "cap", "face": "area"}]}},
{"command": "model.set.create", "params": {"name": "inside", "geometry": [{"body": "cylinder_1", "edge": "inside"}, {"body": "cylinder_2", "edge": "inside"}, {"body": "cylinder_3", "edge": "inside"}, {"body": "cap", "edge": "inside"}]}},
{"command": "model.set.create", "params": {"name": "F", "geometry": [{"body": "cylinder_1", "edge": "bottom"}]}},
{"command": "model.set.create", "params": {"name": "A", "geometry": [{"body": "cap", "edge": "pole"}]}},
{"command": "model.material.create", "params": {"name": "Steel", "elastic": {"type": "isotropic", "young": 210000.0, "poisson": 0.3}}},
{"command": "model.section.create", "params": {"name": "vessel", "set": "all", "material": "Steel", "kind": {"type": "axisymmetric"}}},
{"command": "model.boundary_condition.create", "params": {"name": "F", "type": "symmetry", "target": {"set": "F"}, "plane": "y"}},
{"command": "model.boundary_condition.create", "params": {"name": "A", "type": "symmetry", "target": {"set": "A"}, "plane": "x"}},
{"command": "model.load.create", "params": {"name": "pressure", "type": "pressure", "set": "inside", "magnitude": 1.0}},
{"command": "model.step.create", "params": {"name": "Pressure", "procedure": {"type": "static"}, "boundary_conditions": ["F", "A"], "loads": ["pressure"]}},
{"command": "solve.static", "params": {}, "id": "solve"}
]
+23
View File
@@ -0,0 +1,23 @@
# Electronics cooling examples
`make.py` runs the electronics-cooling studies in [docs/examples.md](../../docs/examples.md) and
regenerates their numbers and pictures in `docs/media/examples/`. Each case compiles its committed
SolidCraft recipe ([`examples/cooling`](../../examples/cooling)) with the `compile_recipe` example,
sets up a thermal lattice-Boltzmann model (the setup is the per-case function in `make.py`), solves
it on the CPU, prints the headline numbers, and renders a still (and, for the transient cases, a
short MP4).
```sh
python3 tools/cooling/make.py heat-sink # build, run, render into docs/media/examples
python3 tools/cooling/make.py heat-sink --no-build # with the release binaries already built
python3 tools/cooling/make.py heat-sink --no-clip # still only, skip the MP4
```
Needs Python 3 (standard library only), a display for the FieldCraft window (`xvfb-run` on a
headless machine), and `ffmpeg` for the MP4s. Honours `CARGO_TARGET_DIR`; set
`FIELDCRAFT_GPU=software` for a software renderer. The solves take a few minutes each.
What the solver does today, and the stand-ins these cases use for what it does not (conjugate heat
transfer into solids #157, fan curves, surface-to-surface radiation), are described in
docs/examples.md under *Electronics cooling studies*. The geometry is our own SolidCraft recipes;
nothing comes from a commercial product.
+314
View File
@@ -0,0 +1,314 @@
#!/usr/bin/env python3
"""Run the electronics-cooling examples and regenerate their gallery numbers and pictures.
Each case sets up a thermal lattice-Boltzmann model from a committed SolidCraft recipe
(`examples/cooling/*.solidcraft.json`, compiled with the `compile_recipe` example), solves it on
the CPU, prints the headline numbers (heat removed, temperatures, flow, thermal resistance), and
renders a still (and, for the transient cases, a short MP4) into `docs/media/examples/`.
What the solver does today and how these cases stand in for what it does not (see docs/examples.md):
* No conjugate heat transfer into solids yet (#157): a high-conductivity heat sink or cold plate
is modelled as an isothermal surface, and a component as a held-temperature or heat-flux face.
* No fan curve: a fan is a uniform inlet velocity. The current TRT collision is resolution-bound
near its stability limit (#557), so the forced cases use a modest duct velocity.
* No surface-to-surface radiation: the sealed-enclosure case is convection only (radiation, which
carries a real share in a sealed box, is noted as missing).
Python standard library only; ffmpeg encodes the MP4s. Needs a display for the app window (run
under xvfb-run on a headless machine) and the release binaries (built unless --no-build).
"""
import argparse
import base64
import json
import os
import shutil
import socket
import subprocess
import sys
import tempfile
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "tools" / "demo-video"))
import caption # noqa: E402
AIR = {
"name": "Air",
"density": 1.2e-12,
"fluid": {"type": "newtonian", "viscosity": 1.8e-11},
"thermal": {"conductivity": 0.026, "specific_heat": 1.005e9},
}
WIDTH, HEIGHT = 1280, 720
CAPTION_FONT = ROOT / "crates" / "ui-egui" / "fonts" / "Inter-SemiBold.ttf"
CAPTION_HEIGHT = 44
FPS = 12
def log(msg):
print(f"cooling: {msg}", file=sys.stderr, flush=True)
def target_dir():
return Path(os.environ.get("CARGO_TARGET_DIR", ROOT / "target"))
def build():
cargo = os.environ.get("CARGO", "cargo")
subprocess.run([cargo, "build", "--release", "-p", "fieldcraft", "-p", "fieldcraft-cli"], cwd=ROOT, check=True)
def compile_recipe(recipe, out):
cargo = os.environ.get("CARGO", "cargo")
subprocess.run(
[cargo, "run", "-q", "--release", "--example", "compile_recipe", "-p", "fieldcraft-geometry",
"--features", "scripts", "--", str(recipe), str(out)],
cwd=ROOT, check=True,
)
def free_port():
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
class Control:
def __init__(self, port, timeout=180.0):
end = time.monotonic() + timeout
while True:
try:
self.sock = socket.create_connection(("127.0.0.1", port), timeout=5.0)
break
except OSError:
if time.monotonic() > end:
raise SystemExit(f"control channel on port {port} did not open")
time.sleep(0.5)
self.sock.settimeout(1200.0)
self.file = self.sock.makefile("rwb")
self.id = 0
def send(self, method, params):
self.id += 1
self.file.write((json.dumps({"id": self.id, "method": method, "params": params}) + "\n").encode())
self.file.flush()
reply = json.loads(self.file.readline())
if not reply.get("ok"):
raise SystemExit(f"{method} {json.dumps(params)[:120]}: {reply.get('error')}")
return reply.get("result")
def execute(self, command, params=None):
return self.send("engine.execute", {"command": command, "params": params or {}})
def close(self):
try:
self.send("app.quit", {})
except (OSError, SystemExit):
pass
self.file.close()
self.sock.close()
def hold_all_faces(ctl, body, value):
"""Every face of `body` held at a temperature (an isothermal high-k solid, or a held component)."""
bodies = ctl.execute("geometry.bodies", {})
b = next(x for x in bodies if x["name"] == body)
return [{"face": {"body": body, "face": f["name"]}, "condition": {"type": "temperature", "value": value}}
for f in b["faces"]]
# ---------------------------------------------------------------------------- heat sink + fan
def heat_sink(ctl, design):
"""Forced convection: air pushed along a 6-fin aluminium sink held isothermal at 80 C, inlet
air at 20 C. Reports the heat removed and the sink-to-air thermal resistance."""
V, Tsink, Tin = 100.0, 80.0, 20.0 # mm/s, C, C
ctl.execute("geometry.open", {"path": str(design)})
surfaces = hold_all_faces(ctl, "HeatSink", Tsink)
ctl.execute("model.units", {"system": "mm-t-s-N-MPa"})
ctl.execute("model.material.create", AIR)
ctl.execute("model.fluid_domain.create", {
"name": "Duct", "material": "Air", "min": [-30, -2, 0], "max": [90, 42, 30], "spacing": 1.0,
"faces": {"x_min": {"type": "velocity", "velocity": [V, 0, 0]}, "x_max": {"type": "pressure", "pressure": 0},
"y_min": {"type": "wall"}, "y_max": {"type": "wall"}, "z_min": {"type": "wall"}, "z_max": {"type": "wall"}},
"obstacles": ["HeatSink"], "initial_velocity": [V, 0, 0],
"thermal": {"initial_temperature": Tin, "faces": {"x_min": {"type": "temperature", "value": Tin}}, "surfaces": surfaces},
})
ctl.execute("model.step.create", {"name": "Cool",
"procedure": {"type": "fluid", "domain": "Duct", "end_time": 4.0, "frames": 40, "time_step": 0.00025}})
log("solving the heat sink (forced convection) ...")
report = ctl.execute("solve.fluid", {"backend": "cpu"})
numbers = heat_sink_numbers(report, Tsink, Tin, V)
render = {"field": "temperature", "clamp": [Tin, Tsink], "streamlines": True,
"camera": {"yaw": -40.0, "pitch": 22.0, "target": {"center_of": "bbox"}, "fit": True},
"caption": "heat sink · forced air 0.1 m/s", "mp4": False}
return report, numbers, render
def heat_sink_numbers(report, Tsink, Tin, V):
heat = report.get("heat", [])
Q = -sum(h["final"] for h in heat if h.get("body") == "HeatSink") # mW removed from the sink
R = (Tsink - Tin) / (Q / 1000.0) if Q else None # K/W
return {
"inlet_velocity_mm_s": V,
"sink_temperature_C": Tsink, "inlet_air_C": Tin,
"heat_removed_W": round(Q / 1000.0, 3),
"thermal_resistance_K_per_W": round(R, 2) if R else None,
"air_temperature_C": report.get("temperature"),
"cells": report.get("cells"), "steps": report.get("steps"),
"density_change": report.get("mass", {}).get("relative_change"),
"warnings": report.get("warnings"),
}
# ---------------------------------------------------------------------------- sealed enclosure (PCB)
def enclosure(ctl, design):
"""Natural convection: a chip on a vertical PCB in a sealed 60x40x60 mm box, no fan. The chip is
held at 40 C, the enclosure walls at 25 C ambient; buoyancy (Boussinesq) drives the plume. The
buoyant start-up needs a small, fixed time step — the auto step over-accelerates and goes
unstable (see the Gaps note in docs/examples.md). Convection only; no radiation."""
Tchip, Tamb = 40.0, 25.0 # C
ctl.execute("geometry.open", {"path": str(design)})
surfaces = hold_all_faces(ctl, "Chip", Tchip)
ctl.execute("model.units", {"system": "mm-t-s-N-MPa"})
ctl.execute("model.material.create", AIR)
wall = {"type": "temperature", "value": Tamb}
ctl.execute("model.fluid_domain.create", {
"name": "Box", "material": "Air", "min": [0, 0, 0], "max": [60, 40, 60], "spacing": 1.0,
"faces": {f: {"type": "wall"} for f in ("x_min", "x_max", "y_min", "y_max", "z_min", "z_max")},
"obstacles": ["PCB", "Chip"], "initial_velocity": [0, 0, 0],
"thermal": {"initial_temperature": Tamb, "gravity": [0, 0, -9810], "expansion": 3.4e-3,
"faces": {f: wall for f in ("x_min", "x_max", "y_min", "y_max", "z_min", "z_max")},
"surfaces": surfaces},
})
ctl.execute("model.step.create", {"name": "Natural",
"procedure": {"type": "fluid", "domain": "Box", "end_time": 3.0, "frames": 40, "time_step": 0.0002}})
log("solving the enclosure (natural convection) ...")
report = ctl.execute("solve.fluid", {"backend": "cpu"})
heat = report.get("heat", [])
Q = -sum(h["final"] for h in heat if h.get("body") == "Chip") # mW shed by the chip
numbers = {
"chip_temperature_C": Tchip, "ambient_C": Tamb,
"heat_shed_W": round(Q / 1000.0, 4),
"chip_to_ambient_K_per_W": round((Tchip - Tamb) / (Q / 1000.0), 1) if Q else None,
"air_temperature_C": report.get("temperature"),
"max_speed_mm_s": round(report.get("max_speed", 0.0), 1),
"cells": report.get("cells"), "steps": report.get("steps"),
"warnings": report.get("warnings"),
}
render = {"field": "temperature", "clamp": [Tamb, Tchip],
"slice": {"axis": "y", "position": 24},
"streamlines": {"count": 14, "max_steps": 1400, "line": {"from": [27, 24, 39], "to": [43, 24, 39]}},
"camera": {"yaw": -14.0, "pitch": 8.0, "target": {"center_of": "bbox"}, "fit": True},
"caption": "sealed enclosure · natural convection", "mp4": False}
return report, numbers, render
CASES = {
"heat-sink": {"recipe": "heatsink.solidcraft.json", "run": heat_sink, "out": "cooling-heat-sink"},
"enclosure": {"recipe": "enclosure.solidcraft.json", "run": enclosure, "out": "cooling-enclosure"},
}
# ---------------------------------------------------------------------------- render + encode
def apply_view(ctl, render):
ctl.send("ui.set", {"dark": False, "style": "shaded_edges", "show_tree": False})
ctl.execute("results.contour", {"field": render["field"], "range": {"min": render["clamp"][0], "max": render["clamp"][1]}})
ctl.execute("results.surface", {"show": True})
if render.get("slice"):
ctl.execute("results.slice", {"field": render["field"], **render["slice"]})
sl = render.get("streamlines")
if sl:
ctl.execute("results.streamlines", dict({"show": True}, **(sl if isinstance(sl, dict) else {})))
ctl.send("ui.camera", render["camera"])
def render_still(ctl, render, out_png):
apply_view(ctl, render)
ctl.send("ui.render", {"path": str(out_png), "width": WIDTH, "height": HEIGHT})
log(f"wrote still {out_png}")
def render_clip(ctl, render, frames_dir, mp4):
frames_dir.mkdir(parents=True, exist_ok=True)
font = caption.Font.load(CAPTION_FONT)
# Discover the frame count from a results reply.
info = ctl.execute("results.slice", {"field": render["field"]})
nframes = info.get("frames", [])
nframes = len(nframes) if isinstance(nframes, list) else 0
if nframes <= 1:
return None
shots = []
for k in range(nframes):
ctl.execute("results.frame", {"frame": k})
shot = frames_dir / f"f{k:04d}.png"
ctl.send("ui.render", {"path": str(shot), "width": WIDTH, "height": HEIGHT})
bar = frames_dir / f"c{k:04d}.png"
bar.write_bytes(caption.caption_bar(font, (WIDTH, CAPTION_HEIGHT), render["caption"], f"frame {k + 1}/{nframes}"))
shots.append((shot, bar))
seqdir = frames_dir / "seq"
shutil.rmtree(seqdir, ignore_errors=True)
(seqdir / "caps").mkdir(parents=True)
i = 0
held = [(s, b, 1) for s, b in shots]
held[-1] = (held[-1][0], held[-1][1], 16)
for s, b, c in held:
for _ in range(c):
os.link(s, seqdir / f"{i:05d}.png")
os.link(b, seqdir / "caps" / f"{i:05d}.png")
i += 1
ffmpeg = os.environ.get("FFMPEG", shutil.which("ffmpeg") or "ffmpeg")
src = ["-framerate", str(FPS), "-i", str(seqdir / "%05d.png"), "-framerate", str(FPS), "-i", str(seqdir / "caps" / "%05d.png")]
cap = f"[0:v]pad={WIDTH}:{HEIGHT + CAPTION_HEIGHT}:0:0[w];[w][1:v]overlay=0:{HEIGHT}"
mp4.parent.mkdir(parents=True, exist_ok=True)
subprocess.run([ffmpeg, "-hide_banner", "-loglevel", "error", "-y", *src, "-filter_complex", cap,
"-c:v", "libx264", "-preset", "veryslow", "-crf", "26", "-pix_fmt", "yuv420p", "-movflags", "+faststart", str(mp4)], check=True)
log(f"wrote clip {mp4} ({i} frames)")
return mp4
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("case", choices=sorted(CASES), help="which example to run")
ap.add_argument("--out", type=Path, default=ROOT / "docs" / "media" / "examples")
ap.add_argument("--no-build", action="store_true")
ap.add_argument("--no-clip", action="store_true", help="still only, skip the MP4")
args = ap.parse_args()
case = CASES[args.case]
if not args.no_build:
build()
bin_dir = target_dir() / "release"
work = Path(tempfile.mkdtemp(prefix="fieldcraft-cooling-"))
design = work / "part.solidcraft"
compile_recipe(ROOT / "examples" / "cooling" / case["recipe"], design)
args.out.mkdir(parents=True, exist_ok=True)
port = free_port()
env = {**os.environ, "FIELDCRAFT_GPU": os.environ.get("FIELDCRAFT_GPU", "software")}
app = subprocess.Popen([str(bin_dir / "fieldcraft"), "--control", str(port)],
stdout=open(work / "app.log", "w"), stderr=subprocess.STDOUT, env=env)
ctl = None
try:
ctl = Control(port)
report, numbers, render = case["run"](ctl, design)
print(json.dumps(numbers, indent=2))
render_still(ctl, render, args.out / f"{case['out']}.png")
if render.get("mp4") and not args.no_clip:
render_clip(ctl, render, work / "frames", args.out / f"{case['out']}-transient.mp4")
finally:
if ctl:
ctl.close()
app.terminate()
try:
app.wait(timeout=10)
except subprocess.TimeoutExpired:
app.kill()
shutil.rmtree(work, ignore_errors=True)
if __name__ == "__main__":
main()
+39
View File
@@ -0,0 +1,39 @@
# Flow-past clip
`make.py` regenerates the gallery clip and still of the `flow-past` sample (docs/examples.md):
`docs/media/examples/flow-past-transient.mp4` (the start-up transient of air flowing past the
L-bracket, from the uniform 25 mm/s initial field to the settled wake) and
`docs/media/examples/flow-past-flow.png` (a clean iso render of the settled flow).
```sh
python3 tools/flow-clip/make.py # build, run, encode into docs/media/examples
python3 tools/flow-clip/make.py --no-build # with the release binaries already built
python3 tools/flow-clip/make.py --frames /tmp/f # also keep the rendered pictures
python3 tools/flow-clip/make.py --gif # also write a GIF (not committed; larger)
```
Needs Python 3 (standard library only), a display for the FieldCraft window (the pictures are
drawn offscreen with `ui.render`, so a locked or virtual display is fine — on a headless machine
run it under `xvfb-run`), and `ffmpeg` with libx264 on the `PATH` (or `FFMPEG`). It honours
`CARGO_TARGET_DIR`; set `FIELDCRAFT_GPU=software` for a software renderer.
What it does:
1. Builds `fieldcraft` and `fieldcraft-cli` (release) and starts the app with `--control PORT`.
2. Opens the `flow-past` sample and, so the transient plays smoothly, edits its fluid step to
store `FLOW_CLIP_FRAMES` frames (48 by default) over the 16 s run (`model.step.edit`), then
solves the lattice Boltzmann step on the CPU (`solve.fluid`).
3. Fixes the velocity legend clamp (0 to 45 mm/s) so the colours mean the same thing from frame
to frame, sets a light theme and an iso view, and renders the model-only viewport at each
field frame (`results.frame` then `ui.render`): velocity on the mid-slice, the bracket
coloured by the flow beside it, and streamlines seeded upstream, coloured by speed.
4. Captions each frame with the elapsed flow time in a band below, drawn by the demo-video tool's
standard-library TrueType caption drawer (`tools/demo-video/caption.py`, Inter SemiBold).
5. Encodes the frames at 12 fps (the settled wake held at the end) into the MP4 (H.264) and writes
the settled still. `--gif` also writes a GIF (800 px, its own palette); it is not committed
because it is several times larger than the MP4.
The clip shows the wake forming behind the upright and the drag settling; it does not shed, which
needs a finer grid or a collision operator stable near τ⁺ = 0.5 (see issue #557). The geometry is
the original upright-bracket SolidCraft recipe (the `flow-past` sample); nothing comes from a
commercial product.
+245
View File
@@ -0,0 +1,245 @@
#!/usr/bin/env python3
"""Regenerate the flow-past gallery clip and still image: the start-up transient of air flowing
past the L-bracket, from the uniform 25 mm/s initial field to the settled wake.
The FieldCraft desktop app opens the `flow-past` sample, solves the lattice Boltzmann step with a
dense frame output, and draws the flow (velocity on a mid-slice, the bracket coloured by the flow
beside it, streamlines seeded upstream). Each frame of the step is rendered through the control
channel (`ui.render`, the model only, no window furniture), with a fixed legend clamp so the
colours mean the same thing from frame to frame. ffmpeg encodes the frames as `docs/media/examples/
flow-past-transient.mp4`, captioned with the elapsed flow time, and a clean iso render of the
settled flow is written as `docs/media/examples/flow-past-flow.png`. `--gif` also writes a GIF (not
committed — it is several times larger than the MP4; use it for hosting the clip elsewhere).
Python standard library only; ffmpeg encodes the frames. caption.py (the demo-video tool's
standard-library TrueType caption drawer) draws the time bars.
"""
import argparse
import json
import os
import shutil
import socket
import subprocess
import sys
import tempfile
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "tools" / "demo-video"))
import caption # noqa: E402 (the demo-video caption drawer, standard library only)
# The flow-past sample: air at 25 mm/s past the upright L-bracket (Reynolds number 83 on the
# 50 mm upright). The sample solves 16 s (about 1.7 passes through the 240 mm box at 25 mm/s); the
# clip plays that start-up transient. FRAMES field frames are stored (the sample's 10 is too coarse
# for a smooth clip), so the clip holds FRAMES + 1 pictures from the uniform initial field (frame 0,
# 25 mm/s everywhere) to the settled flow.
SAMPLE = "flow-past"
STEP = "Flow"
DOMAIN = "Wind"
END_TIME = float(os.environ.get("FLOW_CLIP_END_TIME", 16.0))
FRAMES = int(os.environ.get("FLOW_CLIP_FRAMES", 48))
# The render: a clean iso view of the domain, each frame the model only (no window chrome). The
# legend clamp is fixed over the whole run so the wake's colours are comparable frame to frame;
# 45 mm/s sits just above the settled peak (about 42 mm/s), 0 for the still fluid in the wake.
WIDTH, HEIGHT = 1280, 720
CLAMP = (0.0, 45.0)
CAMERA = {"yaw": -40.0, "pitch": 24.0, "target": {"center_of": "bbox"}, "fit": True}
# The video: frames per second, and how long the settled flow is held at the end.
FPS = 12
HOLD_END = 18 # frames (1.5 s) on the settled wake
# The caption bar, a band under each picture (so it never hides the flow).
CAPTION_FONT = ROOT / "crates" / "ui-egui" / "fonts" / "Inter-SemiBold.ttf"
CAPTION_HEIGHT = 44
def log(msg):
print(f"flow-clip: {msg}", file=sys.stderr, flush=True)
def target_dir():
return Path(os.environ.get("CARGO_TARGET_DIR", ROOT / "target"))
def build():
cargo = os.environ.get("CARGO", "cargo")
subprocess.run([cargo, "build", "--release", "-p", "fieldcraft", "-p", "fieldcraft-cli"], cwd=ROOT, check=True)
def free_port():
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
class Control:
"""A client of the FieldCraft control channel (one JSON line per request and reply)."""
def __init__(self, port, timeout=180.0):
end = time.monotonic() + timeout
while True:
try:
self.sock = socket.create_connection(("127.0.0.1", port), timeout=5.0)
break
except OSError:
if time.monotonic() > end:
raise SystemExit(f"the control channel on port {port} did not open within {timeout:.0f} s")
time.sleep(0.5)
self.sock.settimeout(600.0)
self.file = self.sock.makefile("rwb")
self.next_id = 0
def send(self, method, params):
self.next_id += 1
self.file.write((json.dumps({"id": self.next_id, "method": method, "params": params}) + "\n").encode())
self.file.flush()
reply = json.loads(self.file.readline())
if not reply.get("ok"):
raise SystemExit(f"{method} {params}: {reply.get('error')}")
return reply.get("result")
def execute(self, command, params=None):
return self.send("engine.execute", {"command": command, "params": params or {}})
def close(self):
try:
self.send("app.quit", {})
except (OSError, SystemExit):
pass
self.file.close()
self.sock.close()
def solve(ctl):
"""Open the sample, make the step store FRAMES frames, and solve the flow."""
ctl.execute("file.sample", {"name": SAMPLE})
# The sample's step stores 10 frames; the clip wants a smoother transient.
ctl.execute("model.step.edit", {"name": STEP, "procedure": {"type": "fluid", "domain": DOMAIN, "end_time": END_TIME, "frames": FRAMES}})
log("solving the flow (lattice Boltzmann) …")
report = ctl.execute("solve.fluid", {"backend": "cpu"})
forces = report.get("forces") or report.get("faces")
log(f"solved: {json.dumps(report.get('stats', report.get('summary', {})))[:200]}")
return forces
def render_frames(ctl, frames_dir):
"""Render the model-only viewport at each field frame, with a fixed legend clamp and the iso
view, and draw its time caption. Return the list of (picture, caption) pairs."""
# The flow display the sample opens with: velocity on the mid-slice, the obstacle coloured and
# streamlines upstream. Fix the legend clamp so the colours are comparable across frames.
# A light background and shaded part, to match the gallery's still pictures.
ctl.send("ui.set", {"dark": False, "style": "shaded_edges", "show_tree": False})
ctl.execute("results.contour", {"field": "velocity", "range": {"min": CLAMP[0], "max": CLAMP[1]}})
ctl.execute("results.surface", {"show": True})
ctl.execute("results.streamlines", {"show": True})
ctl.send("ui.camera", CAMERA)
font = caption.Font.load(CAPTION_FONT)
shots = []
for k in range(FRAMES + 1):
ctl.execute("results.frame", {"frame": k})
shot = frames_dir / f"frame{k:04d}.png"
ctl.send("ui.render", {"path": str(shot), "width": WIDTH, "height": HEIGHT})
t = END_TIME * k / FRAMES
bar = frames_dir / f"cap{k:04d}.png"
bar.write_bytes(caption.caption_bar(font, (WIDTH, CAPTION_HEIGHT), "flow past a bracket", f"t = {t:4.1f} s · Re 83"))
shots.append((shot, bar))
return shots
def sequence(shots, folder):
"""Lay the clip out as numbered frames and captions (the last picture held), returning the two
ffmpeg input patterns and the frame count."""
shutil.rmtree(folder, ignore_errors=True)
(folder / "caps").mkdir(parents=True)
n = 0
held = [(shot, bar, 1) for shot, bar in shots]
held[-1] = (held[-1][0], held[-1][1], HOLD_END)
for shot, bar, count in held:
for _ in range(count):
os.link(shot, folder / f"{n:05d}.png")
os.link(bar, folder / "caps" / f"{n:05d}.png")
n += 1
return str(folder / "%05d.png"), str(folder / "caps" / "%05d.png"), n
def encode(shots, frames_dir, mp4, gif=None):
"""Encode the frames into the MP4 (committed to the repo) and, when `gif` is given, a GIF too
(not committed — it is several times larger; use it for hosting the clip elsewhere)."""
ffmpeg = os.environ.get("FFMPEG", shutil.which("ffmpeg") or "ffmpeg")
pattern, caps, count = sequence(shots, frames_dir / "sequence")
log(f"encoding {count} frames ({count / FPS:.1f} s) into {mp4.name}" + (f" and {gif.name}" if gif else ""))
src = ["-framerate", str(FPS), "-i", pattern, "-framerate", str(FPS), "-i", caps]
# Each picture padded with a band below, the caption bar laid over the band.
captioned = f"[0:v]pad={WIDTH}:{HEIGHT + CAPTION_HEIGHT}:0:0[w];[w][1:v]overlay=0:{HEIGHT}"
common = [ffmpeg, "-hide_banner", "-loglevel", "error", "-y"]
mp4.parent.mkdir(parents=True, exist_ok=True)
subprocess.run(
[*common, *src, "-filter_complex", captioned, "-c:v", "libx264", "-preset", "veryslow", "-crf", "26",
"-pix_fmt", "yuv420p", "-movflags", "+faststart", str(mp4)],
check=True,
)
if gif:
subprocess.run(
[*common, *src, "-filter_complex",
f"{captioned},scale=800:-1:flags=lanczos,split[a][b];[a]palettegen=stats_mode=diff[p];"
"[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle", str(gif)],
check=True,
)
def still(ctl, path):
"""A clean iso render of the settled flow for the gallery still."""
ctl.execute("results.frame", {"frame": FRAMES})
ctl.send("ui.render", {"path": str(path), "width": WIDTH, "height": HEIGHT})
log(f"wrote the still {path}")
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--out", type=Path, default=ROOT / "docs" / "media" / "examples", help="folder for the clip and still")
ap.add_argument("--frames", type=Path, help="keep the rendered pictures in this folder")
ap.add_argument("--gif", action="store_true", help="also write the GIF (not committed; for hosting the clip elsewhere)")
ap.add_argument("--no-build", action="store_true", help="use the binaries already built")
args = ap.parse_args()
if not args.no_build:
build()
bin_dir = target_dir() / "release"
work = Path(tempfile.mkdtemp(prefix="fieldcraft-flow-clip-"))
frames_dir = args.frames.resolve() if args.frames else work / "frames"
frames_dir.mkdir(parents=True, exist_ok=True)
args.out.mkdir(parents=True, exist_ok=True)
port = free_port()
app_log = open(work / "app.log", "w")
env = {**os.environ, "FIELDCRAFT_GPU": os.environ.get("FIELDCRAFT_GPU", "software")}
app = subprocess.Popen([str(bin_dir / "fieldcraft"), "--control", str(port)], stdout=app_log, stderr=subprocess.STDOUT, env=env)
ctl = None
try:
ctl = Control(port)
forces = solve(ctl)
if forces:
log(f"face forces: {json.dumps(forces)[:400]}")
shots = render_frames(ctl, frames_dir)
still(ctl, args.out / "flow-past-flow.png")
gif = args.out / "flow-past-transient.gif" if args.gif else None
encode(shots, frames_dir, args.out / "flow-past-transient.mp4", gif)
finally:
if ctl:
ctl.close()
app.terminate()
try:
app.wait(timeout=10)
except subprocess.TimeoutExpired:
app.kill()
app_log.close()
if not args.frames:
shutil.rmtree(work, ignore_errors=True)
if __name__ == "__main__":
main()