forked from 1b-agent/fieldcraft-upstream
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ec1a5c507 |
No files matched your search
@@ -15,8 +15,6 @@ name: CI
|
||||
# Nightly and manual runs, and every release (release.yml), run all of it.
|
||||
on:
|
||||
pull_request:
|
||||
# A draft starts no run; marking it ready for review does.
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
paths:
|
||||
- '**'
|
||||
- '!**/*.md'
|
||||
@@ -58,7 +56,7 @@ env:
|
||||
jobs:
|
||||
plan:
|
||||
name: Plan (affected crates)
|
||||
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]')))
|
||||
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]')
|
||||
runs-on: ${{ github.event_name != 'pull_request' && vars.SELF_HOSTED_RUNNER || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
@@ -116,19 +114,6 @@ jobs:
|
||||
printf 'everything=true\nsolver=true\nwasm=true\n' >>"$GITHUB_OUTPUT"
|
||||
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:
|
||||
name: cargo xtask ci
|
||||
needs: plan
|
||||
@@ -233,7 +218,7 @@ jobs:
|
||||
|
||||
advisories:
|
||||
name: Dependency licences, advisories and sources
|
||||
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]')))
|
||||
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]')
|
||||
runs-on: ${{ github.event_name != 'pull_request' && vars.SELF_HOSTED_RUNNER || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
# `/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'}`);
|
||||
}
|
||||
@@ -9,8 +9,6 @@ on:
|
||||
branches: [develop]
|
||||
paths: [".github/workflows/**", "packaging/**"]
|
||||
pull_request:
|
||||
# A draft starts no run; marking it ready for review does.
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
paths: [".github/workflows/**", "packaging/**"]
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -24,7 +22,6 @@ concurrency:
|
||||
jobs:
|
||||
lint:
|
||||
name: Packaging lint
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
# 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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 });
|
||||
}
|
||||
@@ -94,10 +94,7 @@ People trust FieldCraft with their models; a crash loses their work. **This outr
|
||||
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,
|
||||
MPa); geometry from SolidCraft is in millimetres. Z is up.
|
||||
- **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
|
||||
- **Rust only.** **Never break wasm**: everything below the apps builds for
|
||||
`wasm32-unknown-unknown` (`cargo xtask wasm`, part of `cargo xtask ci`).
|
||||
- **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):
|
||||
|
||||
+1
-5
@@ -25,14 +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-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/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/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/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/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/ibeam.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved ibeam sample (docs/examples.md) | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/thumbnails/ibeam.png | FieldCraft contributors | Original: the solved ibeam sample rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the ibeam sample's thumbnail on the Start card | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/thumbnails/heated-flow.png | FieldCraft contributors | Original: the heated-flow sample's temperature slice rendered directly at 320 × 213 by `fieldcraft-cli snapshot` with the CPU renderer; geometry is the original upright-bracket recipe | MIT OR Apache-2.0 |
|
||||
| docs/media/examples/parts.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the parts sample (meshes only, nothing to solve) (docs/examples.md) | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/thumbnails/flow-past.png | FieldCraft contributors | Original: the flow-past sample as docs/media/examples/flow-past.png shows it, rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the flow-past sample's thumbnail on the Start card | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/thumbnails/cantilever.png | FieldCraft contributors | Original: the cantilever sample as docs/media/examples/cantilever.png shows it, rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the cantilever sample's thumbnail on the Start card | MIT OR Apache-2.0 |
|
||||
@@ -232,7 +229,6 @@ 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/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/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
|
||||
|
||||
|
||||
+4
-38
@@ -61,46 +61,12 @@ 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
|
||||
**claimed**. If what you want to do has no issue, open one first and wait for a reply.
|
||||
2. Comment `/claim` on it. The bot assigns it to you, labels it **claimed** and replies; then it is
|
||||
yours. It refuses an issue someone else holds (naming them and when their claim lapses), an
|
||||
issue an open pull request already covers, and an issue without **help wanted** or **good
|
||||
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).
|
||||
2. Comment that you would like to take it. The maintainer assigns it to you and labels it
|
||||
**claimed**; then it is yours. Work on unassigned issues may not be accepted.
|
||||
3. Open a draft pull request early, linked with `Closes #N`. An issue with no visible progress
|
||||
for 14 days goes back to the pool.
|
||||
|
||||
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
|
||||
|
||||
|
||||
Generated
+22
-22
@@ -1264,7 +1264,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft"
|
||||
version = "0.7.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -1286,7 +1286,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-cli"
|
||||
version = "0.7.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-engine",
|
||||
"fieldcraft-gpu",
|
||||
@@ -1301,7 +1301,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-decks"
|
||||
version = "0.7.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-model",
|
||||
"serde_json",
|
||||
@@ -1310,7 +1310,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-engine"
|
||||
version = "0.7.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-decks",
|
||||
"fieldcraft-explicit",
|
||||
@@ -1334,7 +1334,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-explicit"
|
||||
version = "0.7.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-fem",
|
||||
"fieldcraft-model",
|
||||
@@ -1345,7 +1345,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-fem"
|
||||
version = "0.7.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-model",
|
||||
"thiserror 2.0.21",
|
||||
@@ -1353,7 +1353,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-geometry"
|
||||
version = "0.7.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-model",
|
||||
"quick-xml 0.42.0",
|
||||
@@ -1370,7 +1370,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-gpu"
|
||||
version = "0.7.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-render",
|
||||
"log",
|
||||
@@ -1382,7 +1382,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-io"
|
||||
version = "0.7.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-model",
|
||||
"log",
|
||||
@@ -1395,7 +1395,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-lbm"
|
||||
version = "0.7.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-mesh",
|
||||
"fieldcraft-model",
|
||||
@@ -1405,7 +1405,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-lbm-gpu"
|
||||
version = "0.7.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-gpu",
|
||||
"fieldcraft-lbm",
|
||||
@@ -1418,7 +1418,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-mcp"
|
||||
version = "0.7.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-engine",
|
||||
"fieldcraft-gpu",
|
||||
@@ -1430,7 +1430,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-mesh"
|
||||
version = "0.7.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-model",
|
||||
"fieldcraft-parallel",
|
||||
@@ -1444,7 +1444,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-model"
|
||||
version = "0.7.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1453,14 +1453,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-parallel"
|
||||
version = "0.7.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-render"
|
||||
version = "0.7.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-model",
|
||||
"png",
|
||||
@@ -1470,7 +1470,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-solver"
|
||||
version = "0.7.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-fem",
|
||||
"fieldcraft-mesh",
|
||||
@@ -1482,7 +1482,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-testkit"
|
||||
version = "0.7.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-engine",
|
||||
"fieldcraft-geometry",
|
||||
@@ -1494,7 +1494,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-ui-egui"
|
||||
version = "0.7.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
@@ -1516,7 +1516,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-update"
|
||||
version = "0.7.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"log",
|
||||
@@ -1533,7 +1533,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-web"
|
||||
version = "0.7.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"eframe",
|
||||
@@ -5426,7 +5426,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.7.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ resolver = "3"
|
||||
members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.7.0"
|
||||
version = "0.6.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
+1
-18
@@ -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 |
|
||||
| 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 |
|
||||
| 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: 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: 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 |
|
||||
|
||||
@@ -123,20 +123,3 @@ What exists and what is missing, by area; each gap is in a milestone above.
|
||||
| Fluid dynamics | LBM D3Q19 TRT, walls, inlets/outlets, obstacle forces | Turbulence with wall models, thermal and conjugate heat transfer, non-Newtonian, porous media, multiphase, GPU (M12, M13) |
|
||||
| Wave propagation | One-dimensional elastic waves and impact in the explicit solver (bar cases in the oracle) | A 2D/3D finite-difference ultrasound solver (velocity–stress FDTD, EFIT, k-space pseudospectral) on voxelised geometry, time–frequency diagrams (spectrogram, wavelet, Wigner–Ville) with dispersion overlays, spectral elements, absorbing boundaries, tone-burst and transducer sources, attenuation, Lamb/Rayleigh/Love guided waves and SAFE dispersion curves, ultrasound in fluids and tissue, nonlinear acoustics, A/B-scans and frequency–wavenumber analysis (M23) |
|
||||
| Design | — | Parametric studies on SolidCraft parameters, optimisation, submodelling (M22) |
|
||||
|
||||
### Heated flow results (#157)
|
||||
|
||||
Temperature is available on fluid slices, obstacle surfaces and streamline colours when 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;
|
||||
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).
|
||||
@@ -696,7 +696,7 @@ pub static CATALOGUE: &[Capability] = &[
|
||||
"Streamlines",
|
||||
Results,
|
||||
"Plots",
|
||||
"A fluid step's velocity, pressure or temperature on slices and obstacles, speed or temperature along streamlines, and signed wall heat per area",
|
||||
"A fluid step's streamlines coloured by speed, its velocity or pressure on a slice, and the obstacles coloured by the flow",
|
||||
&["results.slice", "results.surface"],
|
||||
),
|
||||
ready("results.export.vtu", "Export VTU", Results, "Files", "Write a solved step as VTK unstructured grids for ParaView"),
|
||||
|
||||
@@ -1971,7 +1971,6 @@ pub const SAMPLE: &[Field] = &[choice(
|
||||
("drop-test", "Drop test"),
|
||||
("rubber-seal", "Rubber seal"),
|
||||
("flow-past", "Flow past a bracket"),
|
||||
("heated-flow", "Heated flow past a bracket"),
|
||||
("heated-bracket", "Heated bracket"),
|
||||
("bracket-hex", "Hex-meshed bracket"),
|
||||
("crank-arm", "Bicycle crank arm"),
|
||||
@@ -2082,8 +2081,6 @@ pub const CONTOUR: &[Field] = &[
|
||||
("displacement_z", "Displacement Z"),
|
||||
("velocity", "Velocity magnitude"),
|
||||
("pressure", "Pressure (fluid)"),
|
||||
("temperature", "Temperature"),
|
||||
("wall_heat_flux", "Wall heat flux (into wall)"),
|
||||
("max_principal", "Max principal"),
|
||||
("mid_principal", "Mid principal"),
|
||||
("min_principal", "Min principal"),
|
||||
@@ -2125,7 +2122,7 @@ pub const FRAME: &[Field] = &[
|
||||
const FLUID_FRAME: Field = int("frame", "Frame", 0, 1_000_000_000).help("The frame shown, from 0.").hint("the frame shown");
|
||||
pub const SLICE: &[Field] = &[
|
||||
SOLVED,
|
||||
choice("field", "Field", &[("velocity", "Velocity magnitude"), ("pressure", "Pressure"), ("temperature", "Temperature (heated flow)")])
|
||||
choice("field", "Field", &[("velocity", "Velocity magnitude"), ("pressure", "Pressure")])
|
||||
.help("The field coloured on the slice and the obstacles.")
|
||||
.hint("the field shown"),
|
||||
choice("axis", "Normal", &[("x", "X"), ("y", "Y"), ("z", "Z"), ("none", "None (hide)")])
|
||||
@@ -2136,23 +2133,9 @@ pub const SLICE: &[Field] = &[
|
||||
.hint("the position shown"),
|
||||
FLUID_FRAME,
|
||||
];
|
||||
pub const FLOW_SURFACE: &[Field] = &[
|
||||
SOLVED,
|
||||
flag("show", "Show").help("Colour the obstacles by the selected field.").def("true"),
|
||||
choice(
|
||||
"field",
|
||||
"Field",
|
||||
&[("velocity", "Velocity"), ("pressure", "Pressure"), ("temperature", "Temperature"), ("wall_heat_flux", "Wall heat flux (into wall)")],
|
||||
)
|
||||
.help("Colour the obstacle faces by the adjacent fluid field, or signed wall heat received per area.")
|
||||
.hint("the field shown"),
|
||||
FLUID_FRAME,
|
||||
];
|
||||
pub const FLOW_SURFACE: &[Field] = &[SOLVED, flag("show", "Show").help("Colour the obstacles by the field in the fluid beside them.").def("true")];
|
||||
pub const STREAMLINES: &[Field] = &[
|
||||
SOLVED,
|
||||
choice("field", "Field", &[("velocity", "Velocity"), ("pressure", "Pressure (plain lines)"), ("temperature", "Temperature")])
|
||||
.help("Colour streamlines by speed or temperature; pressure leaves the lines in one colour.")
|
||||
.hint("the field shown"),
|
||||
flag("show", "Show").help("Draw the streamlines.").def("true"),
|
||||
int("count", "Count", 1, 2000).help("Seeds on the inlet face, or points along the seed line.").def("64"),
|
||||
int("max_steps", "Max steps", 1, 100_000).help("Most steps of half a cell each streamline takes.").def("4000"),
|
||||
|
||||
@@ -24,7 +24,7 @@ pub const DEFORMED: CommandSpec = CommandSpec {
|
||||
pub const CONTOUR: CommandSpec = CommandSpec {
|
||||
id: "results.contour",
|
||||
label: "Contours",
|
||||
params: r#"{"step": "Load", "field": "von_mises", "bands": 12, "range"?: {"min"?: 0, "max"?: 80} | "auto"} — contours of a nodal field: displacement, displacement_x/y/z, velocity (explicit and fluid steps), pressure (fluid steps), temperature (thermal frames), wall_heat_flux (signed face average on thermal fluid obstacles), von_mises, max/mid/min_principal, stress_xx/yy/zz/xy/xz/yz (stresses for static steps), contact_pressure, contact_gap, contact_status (0 open, 1 closed or stick, 2 slip), contact_shear, contact_slip (friction; nonlinear static steps with contact, at the contacts' secondary surfaces); "none" turns them off; 2 to 24 bands; `range` clamps the legend (a bound left out, null or "auto" follows the field; values above `max` are drawn in the over colour), kept until the field changes"#,
|
||||
params: r#"{"step": "Load", "field": "von_mises", "bands": 12, "range"?: {"min"?: 0, "max"?: 80} | "auto"} — contours of a nodal field: displacement, displacement_x/y/z, velocity (explicit and fluid steps), pressure (fluid steps), von_mises, max/mid/min_principal, stress_xx/yy/zz/xy/xz/yz (stresses for static steps), contact_pressure, contact_gap, contact_status (0 open, 1 closed or stick, 2 slip), contact_shear, contact_slip (friction; nonlinear static steps with contact, at the contacts' secondary surfaces); "none" turns them off; 2 to 24 bands; `range` clamps the legend (a bound left out, null or "auto" follows the field; values above `max` are drawn in the over colour), kept until the field changes"#,
|
||||
form: Some(super::forms::CONTOUR),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[Requirement::Result],
|
||||
@@ -44,7 +44,7 @@ pub const MODES: CommandSpec = CommandSpec {
|
||||
pub const ANIMATE: CommandSpec = CommandSpec {
|
||||
id: "results.animate",
|
||||
label: "Animate",
|
||||
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"#,
|
||||
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"#,
|
||||
form: Some(super::forms::ANIMATE),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[Requirement::Result],
|
||||
@@ -94,7 +94,7 @@ pub const EXPORT_CSV: CommandSpec = CommandSpec {
|
||||
pub const SLICE: CommandSpec = CommandSpec {
|
||||
id: "results.slice",
|
||||
label: "Flow Slice",
|
||||
params: r#"{"step": "Flow", "field": "velocity" | "pressure" | "temperature", "axis": "x" | "y" | "z" | "none", "position": 20 | "auto", "frame": 10} — shows a fluid step's field (velocity magnitude, gauge pressure or temperature when its frames carry heat, banded with the legend as results.contour sets it) on a slice through the domain normal to `axis`, at `position` along it (inside the domain; "auto" or left out with a new axis: the domain's middle); "none" hides the slice; `frame` from 0 (left out: the one shown, at first the last); what is left out stays as shown. A fluid step is first shown with velocity on a slice through the middle, normal to the horizontal axis across the flow"#,
|
||||
params: r#"{"step": "Flow", "field": "velocity" | "pressure", "axis": "x" | "y" | "z" | "none", "position": 20 | "auto", "frame": 10} — shows a fluid step's field (velocity magnitude or gauge pressure, banded with the legend as results.contour sets it) on a slice through the domain normal to `axis`, at `position` along it (inside the domain; "auto" or left out with a new axis: the domain's middle); "none" hides the slice; `frame` from 0 (left out: the one shown, at first the last); what is left out stays as shown. A fluid step is first shown with velocity on a slice through the middle, normal to the horizontal axis across the flow"#,
|
||||
form: Some(super::forms::SLICE),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[Requirement::Result],
|
||||
@@ -104,7 +104,7 @@ pub const SLICE: CommandSpec = CommandSpec {
|
||||
pub const SURFACE: CommandSpec = CommandSpec {
|
||||
id: "results.surface",
|
||||
label: "Flow on Surfaces",
|
||||
params: r#"{"step": "Flow", "show": true, "field"?: "velocity" | "pressure" | "temperature" | "wall_heat_flux", "frame"?: 10} — colours a fluid step's obstacles (the part drawn) by the field shown, read in the fluid cells beside them; `wall_heat_flux` is signed power received by each obstacle face divided by its complete tessellated area, in model power/area units (positive into the wall); histories interpolate at the shown frame without extrapolation; recorded adiabatic faces are zero, missing histories remain uncoloured; no slice is drawn for this surface-only field. `show: false` draws the part in its own colours"#,
|
||||
params: r#"{"step": "Flow", "show": true} — colours a fluid step's obstacles (the part drawn) by the field shown, read in the fluid cells beside them; `show: false` draws the part in its own colours"#,
|
||||
form: Some(super::forms::FLOW_SURFACE),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[Requirement::Result],
|
||||
@@ -114,7 +114,7 @@ pub const SURFACE: CommandSpec = CommandSpec {
|
||||
pub const STREAMLINES: CommandSpec = CommandSpec {
|
||||
id: "results.streamlines",
|
||||
label: "Streamlines",
|
||||
params: r#"{"step": "Flow", "show": true, "field"?: "velocity" | "pressure" | "temperature", "count": 64, "max_steps": 4000, "seeds": [[x, y, z], …], "line": {"from": [x, y, z], "to": [x, y, z]}, "frame": 10} — streamlines of a fluid step, traced downstream through its velocity field (trilinear between the cells, midpoint rule, half-cell steps) and coloured by speed or temperature when that field is selected (plain for pressure): from `seeds` (at most 2000 points), from `count` points evenly along `line`, or else from `count` seeds (1 to 2000, default 64) on the inlet face upstream of the obstacles; each at most `max_steps` steps (1 to 100000, default 4000), 2 000 000 points in all; `show: false` hides them; `frame` as for results.slice. Replies with the view and `traced`: the streamlines, their points, the seeds and how many started none (outside the fluid)"#,
|
||||
params: r#"{"step": "Flow", "show": true, "count": 64, "max_steps": 4000, "seeds": [[x, y, z], …], "line": {"from": [x, y, z], "to": [x, y, z]}, "frame": 10} — streamlines of a fluid step, traced downstream through its velocity field (trilinear between the cells, midpoint rule, half-cell steps) and coloured by speed when the legend shows velocity: from `seeds` (at most 2000 points), from `count` points evenly along `line`, or else from `count` seeds (1 to 2000, default 64) on the inlet face upstream of the obstacles; each at most `max_steps` steps (1 to 100000, default 4000), 2 000 000 points in all; `show: false` hides them; `frame` as for results.slice. Replies with the view and `traced`: the streamlines, their points, the seeds and how many started none (outside the fluid)"#,
|
||||
form: Some(super::forms::STREAMLINES),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[Requirement::Result],
|
||||
@@ -249,27 +249,12 @@ fn contour(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let (Some(f), Some((r, _))) = (field, s.latest_result(&step))
|
||||
&& !Field::available(r).any(|a| a == f)
|
||||
{
|
||||
if f == Field::Temperature && matches!(r.kind(), Kind::Fluid(_)) {
|
||||
return Err(EngineError::Params(format!(
|
||||
"temperature is not available for fluid step `{step}`: its frames have no complete temperature field (isothermal flow)"
|
||||
)));
|
||||
}
|
||||
if f == Field::WallHeatFlux {
|
||||
return Err(EngineError::Params(format!("wall heat flux is not available for step `{step}`: it requires thermal obstacle histories")));
|
||||
}
|
||||
return Err(EngineError::Params(format!("{} is not available for step `{step}` (mode shapes have no stress scale)", f.label())));
|
||||
}
|
||||
let range = match p.get("range") {
|
||||
None => None,
|
||||
Some(r) => Some(legend_range(r)?),
|
||||
};
|
||||
if field == Some(Field::WallHeatFlux)
|
||||
&& let Some((r, _)) = s.latest_result(&step)
|
||||
&& let Kind::Fluid(f) = r.kind()
|
||||
{
|
||||
let pick = s.results_view.as_ref().filter(|v| v.step == step).map(|v| v.pick()).unwrap_or_default();
|
||||
crate::flow::wall::faces(s, f, pick)?;
|
||||
}
|
||||
let v = view_of(s, &step)?;
|
||||
if v.contour != field {
|
||||
(v.range_min, v.range_max) = (None, None);
|
||||
@@ -341,12 +326,12 @@ fn animate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Ok(s.results_report());
|
||||
}
|
||||
let step = step_for(s, &json!({}), Want::Any)?;
|
||||
let fluid = s.latest_result(&step).is_some_and(|(r, _)| matches!(r.kind(), Kind::Fluid(_)));
|
||||
if 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)?;
|
||||
v.animate = 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;
|
||||
v.deformed = true;
|
||||
Ok(s.results_report())
|
||||
}
|
||||
|
||||
@@ -418,7 +403,12 @@ fn seed_point(v: &Value) -> Option<[f64; 3]> {
|
||||
fn slice(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (step, r) = fluid_step(s, p)?;
|
||||
let Kind::Fluid(f) = r.kind() else { return Err(EngineError::Params(format!("step `{step}` is not a fluid step"))) };
|
||||
let field = fluid_field(p, &r)?;
|
||||
let field = match opt::<String>(p, "field")?.as_deref() {
|
||||
None => None,
|
||||
Some("velocity") => Some(Field::Velocity),
|
||||
Some("pressure") => Some(Field::Pressure),
|
||||
Some(_) => return Err(EngineError::invalid("field", "must be \"velocity\" or \"pressure\"")),
|
||||
};
|
||||
// Some(None): hide the slice.
|
||||
let axis = match opt::<String>(p, "axis")?.as_deref() {
|
||||
None => None,
|
||||
@@ -473,27 +463,7 @@ fn surface(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (step, r) = fluid_step(s, p)?;
|
||||
let Kind::Fluid(f) = r.kind() else { return Err(EngineError::Params(format!("step `{step}` is not a fluid step"))) };
|
||||
let show = opt::<bool>(p, "show")?.unwrap_or(true);
|
||||
let field = if opt::<String>(p, "field")?.as_deref() == Some("wall_heat_flux") {
|
||||
if !Field::available(&r).any(|f| f == Field::WallHeatFlux) {
|
||||
return Err(EngineError::invalid("field", "wall heat flux is unavailable: this step has no thermal obstacle histories"));
|
||||
}
|
||||
Some(Field::WallHeatFlux)
|
||||
} else {
|
||||
fluid_field(p, &r)?
|
||||
};
|
||||
let frame = fluid_frame(f, p)?;
|
||||
if field == Some(Field::WallHeatFlux) {
|
||||
let mut pick = s.results_view.as_ref().filter(|v| v.step == step).map(|v| v.pick()).unwrap_or_default();
|
||||
if frame.is_some() {
|
||||
pick.frame = frame;
|
||||
}
|
||||
crate::flow::wall::faces(s, f, pick)?;
|
||||
}
|
||||
let v = view_of(s, &step)?;
|
||||
set_fluid_field(v, field);
|
||||
if frame.is_some() {
|
||||
v.frame = frame;
|
||||
}
|
||||
let mut flow = FlowView::of(v, f).into_owned();
|
||||
flow.surface = show;
|
||||
v.flow = Some(flow);
|
||||
@@ -504,7 +474,6 @@ fn streamlines(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (step, r) = fluid_step(s, p)?;
|
||||
let Kind::Fluid(f) = r.kind() else { return Err(EngineError::Params(format!("step `{step}` is not a fluid step"))) };
|
||||
let show = opt::<bool>(p, "show")?.unwrap_or(true);
|
||||
let field = fluid_field(p, &r)?;
|
||||
let count = count_param(p, "count", MAX_STREAMLINES)?;
|
||||
let max_steps = count_param(p, "max_steps", MAX_STREAMLINE_STEPS)?;
|
||||
let max = MAX_STREAMLINES as usize;
|
||||
@@ -533,7 +502,6 @@ fn streamlines(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let frame = fluid_frame(f, p)?;
|
||||
let v = view_of(s, &step)?;
|
||||
set_fluid_field(v, field);
|
||||
let mut flow = FlowView::of(v, f).into_owned();
|
||||
flow.streamlines = if show {
|
||||
let mut sl = flow.streamlines.take().unwrap_or_default();
|
||||
@@ -569,32 +537,6 @@ fn streamlines(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn fluid_field(p: &Value, r: &crate::StepResult) -> Result<Option<Field>> {
|
||||
let field = match opt::<String>(p, "field")?.as_deref() {
|
||||
None => return Ok(None),
|
||||
Some("velocity") => Field::Velocity,
|
||||
Some("pressure") => Field::Pressure,
|
||||
Some("temperature") => Field::Temperature,
|
||||
Some(_) => return Err(EngineError::invalid("field", "must be velocity, pressure or temperature")),
|
||||
};
|
||||
if !Field::available(r).any(|f| f == field) {
|
||||
return Err(EngineError::invalid(
|
||||
"field",
|
||||
"temperature is unavailable: this isothermal step has no complete temperature field in its frames",
|
||||
));
|
||||
}
|
||||
Ok(Some(field))
|
||||
}
|
||||
|
||||
fn set_fluid_field(view: &mut ResultsView, field: Option<Field>) {
|
||||
if let Some(field) = field {
|
||||
if view.contour != Some(field) {
|
||||
(view.range_min, view.range_max) = (None, None);
|
||||
}
|
||||
view.contour = Some(field);
|
||||
}
|
||||
}
|
||||
|
||||
fn clear(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.results_view = None;
|
||||
Ok(json!({"view": null}))
|
||||
@@ -1484,10 +1426,7 @@ mod tests {
|
||||
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_eq!(s.execute("results.surface", &json!({"show": false})).unwrap()["surface"], false);
|
||||
// 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!(s.execute("results.animate", &json!({})).is_err(), "nothing deforms");
|
||||
assert_eq!((s.journal.len(), s.revision()), (journal, revision), "display commands are not edits");
|
||||
// Structural steps have none of it.
|
||||
let mut t = solved();
|
||||
@@ -1497,43 +1436,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temperature_views_require_thermal_frames_and_follow_the_selected_frame() {
|
||||
let mut r = crate::flow::tests::channel();
|
||||
r.frames[0].temperature = vec![20.0; r.grid.len()];
|
||||
r.frames[1].temperature = vec![70.0; r.grid.len()];
|
||||
let mut s = Session::default();
|
||||
s.store_result(crate::StepResult::Fluid(r));
|
||||
for command in ["results.slice", "results.surface", "results.streamlines", "results.contour"] {
|
||||
let report = s.execute(command, &json!({"field": "temperature"})).unwrap();
|
||||
assert_eq!(report["contour"]["field"], "temperature", "{command}: {report}");
|
||||
assert_eq!(report["contour"]["range"]["max"]["value"], 70.0);
|
||||
}
|
||||
let report = s.execute("results.frame", &json!({"frame": 0})).unwrap();
|
||||
assert_eq!(report["contour"]["range"]["max"]["value"], 20.0);
|
||||
assert!(Field::available(s.latest_result("Flow").unwrap().0).any(|f| f == Field::Temperature));
|
||||
let before = s.results_view.clone();
|
||||
for command in ["results.slice", "results.surface", "results.streamlines"] {
|
||||
for params in [json!({"field": "temperature", "frame": u64::MAX}), json!({"field": ["temperature"]})] {
|
||||
assert!(s.execute(command, ¶ms).is_err());
|
||||
assert_eq!(s.results_view, before);
|
||||
}
|
||||
}
|
||||
let mut incomplete = crate::flow::tests::channel();
|
||||
incomplete.frames[0].temperature = vec![20.0; incomplete.grid.len()];
|
||||
incomplete.frames[1].temperature = vec![70.0; incomplete.grid.len().saturating_sub(1)];
|
||||
let incomplete = crate::StepResult::Fluid(incomplete);
|
||||
assert!(!Field::available(&incomplete).any(|f| f == Field::Temperature));
|
||||
let mut cold = fluid();
|
||||
let before = cold.results_view.clone();
|
||||
for command in ["results.slice", "results.surface", "results.streamlines", "results.contour"] {
|
||||
let error = cold.execute(command, &json!({"field": "temperature"})).unwrap_err().to_string();
|
||||
assert!(error.contains("isothermal"), "{command}: {error}");
|
||||
assert_eq!(cold.results_view, before);
|
||||
}
|
||||
assert!(!Field::available(cold.latest_result("Flow").unwrap().0).any(|f| f == Field::Temperature));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fluid_display_hostile_params_never_panic() {
|
||||
let s = fluid();
|
||||
|
||||
@@ -110,7 +110,7 @@ pub const FLUID: CommandSpec = CommandSpec {
|
||||
pub const PROBE: CommandSpec = CommandSpec {
|
||||
id: "solve.probe",
|
||||
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, 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)"#,
|
||||
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)"#,
|
||||
form: Some(super::forms::SOLVE_PROBE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Result],
|
||||
@@ -717,7 +717,6 @@ fn probe_implicit(result: &ImplicitResult, step: &str, nodes: &[u32], p: &Value)
|
||||
"reaction": r.reaction.get(i),
|
||||
"reaction_moment": r.reaction_moment.get(i),
|
||||
"stress": r.stress.get(i),
|
||||
"principal": r.stress.get(i).map(fieldcraft_fem::principal),
|
||||
"von_mises": r.von_mises.get(i),
|
||||
"temperature": r.temperature.as_ref().and_then(|t| t.get(i)),
|
||||
});
|
||||
@@ -946,10 +945,6 @@ pub(crate) mod tests {
|
||||
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"][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.
|
||||
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}");
|
||||
|
||||
+11
-235
@@ -1,5 +1,5 @@
|
||||
//! The display of a fluid step's result: its fields per cell drawn as a coloured slice through the
|
||||
//! domain, on the obstacles' surfaces beside the fluid, and as streamlines coloured by speed or temperature
|
||||
//! domain, on the obstacles' surfaces beside the fluid, and as streamlines coloured by speed
|
||||
//! ([`fieldcraft_render::flow`]), as the results view's [`FlowView`] asks. The headless pictures
|
||||
//! ([`crate::view`]) and the desktop viewport draw the same [`FlowPlot`]; the part stays drawn
|
||||
//! (a fluid step does not deform it).
|
||||
@@ -21,8 +21,6 @@ use serde_json::{Value, json};
|
||||
use crate::results::{Field, Kind, Pick, ResultsView, magnitude};
|
||||
use crate::{EngineError, Result, StepResult};
|
||||
|
||||
pub mod wall;
|
||||
|
||||
/// Streamlines traced when no count is given, and the steps (half a cell each) each may take.
|
||||
pub const DEFAULT_STREAMLINES: u32 = 64;
|
||||
pub const DEFAULT_STREAMLINE_STEPS: u32 = 4_000;
|
||||
@@ -92,7 +90,7 @@ impl Default for StreamlineView {
|
||||
}
|
||||
|
||||
/// What a fluid step's display shows besides the field's legend (the view's contour field:
|
||||
/// velocity, pressure, temperature or wall heat flux; `None` draws no colours).
|
||||
/// velocity or pressure, `None` draws no colours).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct FlowView {
|
||||
/// The slice, `None` for none.
|
||||
@@ -159,29 +157,14 @@ pub fn direction(r: &FluidResult, f: &Frame) -> (Axis, bool) {
|
||||
(axis, sum[axis.index()] >= 0.0)
|
||||
}
|
||||
|
||||
/// A cell field's value in every cell of a frame: velocity magnitude, pressure or temperature, NaN in the
|
||||
/// A cell field's value in every cell of a frame: velocity magnitude or pressure, NaN in the
|
||||
/// solid cells.
|
||||
pub fn cell_values(r: &FluidResult, field: Field, f: &Frame) -> Result<Vec<f64>> {
|
||||
let solid = |k: usize| r.solid.get(k).copied().unwrap_or(false);
|
||||
let values: Vec<f64> = match field {
|
||||
Field::Velocity => f.velocity.iter().map(|v| magnitude(v.map(f64::from))).collect(),
|
||||
Field::Pressure => f.pressure.iter().map(|p| f64::from(*p)).collect(),
|
||||
Field::Temperature if f.temperature.len() == r.grid.len() && !f.temperature.is_empty() => {
|
||||
f.temperature.iter().map(|t| f64::from(*t)).collect()
|
||||
}
|
||||
Field::Temperature => {
|
||||
return Err(EngineError::Params(format!(
|
||||
"temperature is not available for fluid step `{}`: this frame has no complete temperature field (isothermal flow)",
|
||||
r.step
|
||||
)));
|
||||
}
|
||||
_ => {
|
||||
return Err(EngineError::Params(format!(
|
||||
"{} is not available for fluid step `{}` (velocity, pressure or temperature)",
|
||||
field.label(),
|
||||
r.step
|
||||
)));
|
||||
}
|
||||
_ => return Err(EngineError::Params(format!("{} is not available for fluid step `{}` (velocity or pressure)", field.label(), r.step))),
|
||||
};
|
||||
Ok(values.into_iter().enumerate().map(|(k, v)| if solid(k) || !v.is_finite() { f64::NAN } else { v }).collect())
|
||||
}
|
||||
@@ -303,10 +286,6 @@ pub struct FlowPlot {
|
||||
/// The bands streamlines are coloured by speed over: the legend's when it shows velocity;
|
||||
/// `None` draws them in one colour (the legend shows another field).
|
||||
pub speed: Option<Bands>,
|
||||
/// Face-averaged heat values paired with the displayed geometry triangles.
|
||||
pub wall_values: Vec<([Vec3; 3], Option<f64>)>,
|
||||
/// A point on each recorded wall face for the legend's extreme markers.
|
||||
pub wall_positions: Vec<Option<Vec3>>,
|
||||
}
|
||||
|
||||
impl FlowPlot {
|
||||
@@ -322,7 +301,7 @@ pub fn plot(view: &ResultsView, result: &Arc<StepResult>, traced: &TraceCache) -
|
||||
let k = frame_index(r, view.pick())?;
|
||||
let f = frame(r, view.pick())?;
|
||||
let fv = FlowView::of(view, r).into_owned();
|
||||
let contour = match view.contour.filter(|c| matches!(c, Field::Velocity | Field::Pressure | Field::Temperature)) {
|
||||
let contour = match view.contour.filter(|c| matches!(c, Field::Velocity | Field::Pressure)) {
|
||||
Some(field) => {
|
||||
let values = cell_values(r, field, f)?;
|
||||
cell_range(&values).map(|(min, max)| CellContour { field, bands: view.bands(min.1, max.1), min, max, values })
|
||||
@@ -342,108 +321,7 @@ pub fn plot(view: &ResultsView, result: &Arc<StepResult>, traced: &TraceCache) -
|
||||
None => Arc::default(),
|
||||
};
|
||||
let speed = contour.as_ref().filter(|c| c.field == Field::Velocity).map(|c| c.bands);
|
||||
Ok(FlowPlot { grid: g, contour, slice, surface: fv.surface, streamlines, speed, view: fv, wall_values: Vec::new(), wall_positions: Vec::new() })
|
||||
}
|
||||
|
||||
/// Add the obstacle face mapping shared by the desktop and headless views.
|
||||
pub fn plot_with_surfaces(
|
||||
view: &ResultsView,
|
||||
result: &Arc<StepResult>,
|
||||
traced: &TraceCache,
|
||||
session: &crate::Session,
|
||||
base: &fieldcraft_render::DisplayMesh,
|
||||
bodies: &[&str],
|
||||
) -> Result<FlowPlot> {
|
||||
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) {
|
||||
let Kind::Fluid(r) = result.kind() else { return Ok(out) };
|
||||
let faces = wall::faces(session, r, view.pick())?;
|
||||
let positions: std::collections::BTreeMap<_, _> = base
|
||||
.surface_tris
|
||||
.iter()
|
||||
.filter_map(|t| bodies.get(t.body as usize).map(|name| ((*name, t.face), (t.p[0] + t.p[1] + t.p[2]) * (1.0 / 3.0))))
|
||||
.collect();
|
||||
out.wall_positions = faces.iter().map(|f| positions.get(&(f.body.as_str(), f.index)).copied()).collect();
|
||||
let values: Vec<f64> = faces.iter().map(|f| f.flux.unwrap_or(f64::NAN)).collect();
|
||||
out.contour =
|
||||
cell_range(&values).map(|(min, max)| CellContour { field: Field::WallHeatFlux, bands: view.bands(min.1, max.1), min, max, values });
|
||||
out.wall_values = base.surface_tris.iter().zip(wall::triangle_values(&faces, base, bodies)).map(|(t, v)| (t.p, v)).collect();
|
||||
}
|
||||
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
|
||||
Ok(FlowPlot { grid: g, contour, slice, surface: fv.surface, streamlines, speed, view: fv })
|
||||
}
|
||||
|
||||
/// The colours a flow plot is drawn in.
|
||||
@@ -452,7 +330,7 @@ pub struct FlowColors<'a> {
|
||||
/// The contour scale, low to high, and values above a clamped legend's maximum.
|
||||
pub stops: &'a [Rgb],
|
||||
pub over: Rgb,
|
||||
/// Streamlines when the legend shows neither velocity nor temperature.
|
||||
/// Streamlines when the legend shows another field than velocity.
|
||||
pub plain: Rgb,
|
||||
}
|
||||
|
||||
@@ -461,29 +339,9 @@ pub struct FlowColors<'a> {
|
||||
pub fn draw(plot: &FlowPlot, scene: &mut Scene, colors: &FlowColors) {
|
||||
if let Some(c) = &plot.contour {
|
||||
let color = |band: Option<u32>| band.map_or(colors.plain, |k| bands_color(colors.stops, colors.over, &c.bands, k));
|
||||
if plot.surface && c.field == Field::WallHeatFlux {
|
||||
// Only tagged geometry triangles can receive wall heat. A structural mesh or
|
||||
// hidden geometry must not accidentally inherit a face's value by index.
|
||||
if scene.tris.len() == plot.wall_values.len() {
|
||||
for (tri, (points, value)) in scene.tris.iter_mut().zip(&plot.wall_values) {
|
||||
if tri.p == *points
|
||||
&& let Some(v) = value
|
||||
{
|
||||
tri.color = color(c.bands.band(*v));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if plot.surface {
|
||||
if plot.surface {
|
||||
let model = std::mem::take(&mut scene.tris);
|
||||
// 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;
|
||||
}
|
||||
for t in model {
|
||||
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));
|
||||
if known == 0 {
|
||||
@@ -504,14 +362,7 @@ pub fn draw(plot: &FlowPlot, scene: &mut Scene, colors: &FlowColors) {
|
||||
for (k, w) in l.points.windows(2).enumerate() {
|
||||
let (Some(&a), Some(&b)) = (w.first(), w.get(1)) else { continue };
|
||||
let speed = 0.5 * (l.speed.get(k).copied().unwrap_or(0.0) + l.speed.get(k + 1).copied().unwrap_or(0.0));
|
||||
let temperature = plot.contour.as_ref().filter(|c| c.field == Field::Temperature);
|
||||
let color = if let Some(c) = temperature {
|
||||
let value = flow::sample(&plot.grid, &c.values, (a + b) * 0.5);
|
||||
c.bands.band(value).map(|k| bands_color(colors.stops, colors.over, &c.bands, k))
|
||||
} else {
|
||||
plot.speed.as_ref().and_then(|b| b.band(speed).map(|k| bands_color(colors.stops, colors.over, b, k)))
|
||||
}
|
||||
.unwrap_or(colors.plain);
|
||||
let color = plot.speed.as_ref().and_then(|b| b.band(speed).map(|k| bands_color(colors.stops, colors.over, b, k))).unwrap_or(colors.plain);
|
||||
scene.lines.push(SceneLine { a, b, color, width: STREAMLINE_WIDTH });
|
||||
}
|
||||
}
|
||||
@@ -535,7 +386,7 @@ pub fn report(view: &ResultsView, r: &FluidResult, units: fieldcraft_model::Unit
|
||||
}
|
||||
}
|
||||
let fv = FlowView::of(view, r);
|
||||
out["slice"] = match fv.slice.as_ref().filter(|_| view.contour != Some(Field::WallHeatFlux)) {
|
||||
out["slice"] = match &fv.slice {
|
||||
Some(s) => json!({"axis": s.axis, "position": slice_position(r, s)}),
|
||||
None => Value::Null,
|
||||
};
|
||||
@@ -624,57 +475,6 @@ pub(crate) mod tests {
|
||||
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]
|
||||
fn a_fluid_result_is_first_shown_across_the_flow() {
|
||||
let r = channel();
|
||||
@@ -696,30 +496,6 @@ pub(crate) mod tests {
|
||||
assert!(s.iter().all(|p| p.y == 3.5), "{s:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temperature_colours_cells_and_streamlines_and_masks_solids() {
|
||||
let mut r = channel();
|
||||
for f in &mut r.frames {
|
||||
f.temperature = (0..r.grid.len()).map(|k| 20.0 + 2.0 * r.grid.coords(k)[0] as f32).collect();
|
||||
}
|
||||
let values = cell_values(&r, Field::Temperature, &r.frames[1]).unwrap();
|
||||
assert_eq!(values.iter().filter(|v| v.is_nan()).count(), 8);
|
||||
assert_eq!(cell_range(&values).map(|(a, b)| (a.1, b.1)), Some((20.0, 38.0)));
|
||||
let result = Arc::new(crate::StepResult::Fluid(r));
|
||||
let mut view = ResultsView::for_result(&result);
|
||||
view.contour = Some(Field::Temperature);
|
||||
let p = plot(&view, &result, &TraceCache::default()).unwrap();
|
||||
let mut scene = Scene::default();
|
||||
let colors = FlowColors { stops: &[Rgb(0, 0, 255), Rgb(255, 0, 0)], over: Rgb(255, 255, 255), plain: Rgb(7, 7, 7) };
|
||||
draw(&p, &mut scene, &colors);
|
||||
assert!(!scene.tris.is_empty());
|
||||
assert!(!scene.lines.is_empty());
|
||||
assert!(scene.lines.iter().all(|l| l.color != colors.plain));
|
||||
assert!(scene.lines.windows(2).any(|l| l[0].color != l[1].color));
|
||||
let cold = channel();
|
||||
assert!(cell_values(&cold, Field::Temperature, &cold.frames[1]).unwrap_err().to_string().contains("isothermal"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_fields_leave_the_solid_out() {
|
||||
let r = channel();
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
//! Face-averaged heat received by obstacle walls, in the model's consistent units.
|
||||
//! The history is interpolated only between recorded samples: frame zero predates it.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use fieldcraft_lbm::FluidResult;
|
||||
use fieldcraft_render::{DisplayMesh, Vec3};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::results::Pick;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct FaceHeat {
|
||||
pub body: String,
|
||||
pub face: String,
|
||||
pub area: f64,
|
||||
/// Positive into the wall. None before the first recorded history sample.
|
||||
pub power: Option<f64>,
|
||||
pub flux: Option<f64>,
|
||||
#[serde(skip)]
|
||||
pub index: u32,
|
||||
}
|
||||
|
||||
fn interpolate(times: &[f64], values: &[f64], time: f64) -> Option<f64> {
|
||||
if !time.is_finite()
|
||||
|| times.len() != values.len()
|
||||
|| times.is_empty()
|
||||
|| times.iter().chain(values).any(|v| !v.is_finite())
|
||||
|| times.windows(2).any(|p| p[0] >= p[1])
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let k = times.partition_point(|t| *t < time);
|
||||
if times.get(k) == Some(&time) {
|
||||
return values.get(k).copied();
|
||||
}
|
||||
let lo = k.checked_sub(1)?;
|
||||
let (a, b) = (*times.get(lo)?, *times.get(k)?);
|
||||
let weight = (time - a) / (b - a);
|
||||
let value = (1.0 - weight) * values.get(lo)? + weight * values.get(k)?;
|
||||
value.is_finite().then_some(value)
|
||||
}
|
||||
|
||||
/// All recorded obstacle faces, using their complete tessellated geometric area (not the
|
||||
/// visible triangle area or a lattice-link count). Missing geometry is an error, never zero.
|
||||
pub fn faces(s: &Session, r: &FluidResult, pick: Pick) -> Result<Vec<FaceHeat>> {
|
||||
if s.latest_result(&r.step).is_some_and(|(_, current)| !current) {
|
||||
return Err(EngineError::Params("wall heat requires current geometry: solve the edited model again before dividing by face area".into()));
|
||||
}
|
||||
let time = super::frame(r, pick)?.time;
|
||||
let mut areas = BTreeMap::new();
|
||||
let mut output = Vec::new();
|
||||
for h in r.heat.iter().filter(|h| h.body.is_some()) {
|
||||
let Some(name) = h.body.as_deref() else { continue };
|
||||
let body = s
|
||||
.design
|
||||
.iter()
|
||||
.flat_map(|d| &d.bodies)
|
||||
.find(|b| b.name == name)
|
||||
.ok_or_else(|| EngineError::Params(format!("wall heat: obstacle `{name}` is no longer in the design")))?;
|
||||
let index = body
|
||||
.face_index(&h.name)
|
||||
.ok_or_else(|| EngineError::Params(format!("wall heat: face `{}` of `{name}` is no longer in the design", h.name)))?;
|
||||
if let std::collections::btree_map::Entry::Vacant(entry) = areas.entry(name.to_owned()) {
|
||||
let mesh = body.tessellate((body.size()? * 0.001).max(1e-4))?;
|
||||
let mut by_face = BTreeMap::<u32, f64>::new();
|
||||
for (tri, face) in mesh.triangles.iter().zip(&mesh.tri_face) {
|
||||
let point = |k: u32| mesh.positions.get(k as usize).copied().map(Vec3::from);
|
||||
let (Some(a), Some(b), Some(c)) = (point(tri[0]), point(tri[1]), point(tri[2])) else { continue };
|
||||
*by_face.entry(*face).or_default() += 0.5 * (b - a).cross(c - a).len();
|
||||
}
|
||||
entry.insert(by_face);
|
||||
}
|
||||
let area = areas
|
||||
.get(name)
|
||||
.and_then(|a| a.get(&index))
|
||||
.copied()
|
||||
.filter(|a| a.is_finite() && *a > 0.0)
|
||||
.ok_or_else(|| EngineError::Params(format!("wall heat: face `{}` of `{name}` has no finite positive area", h.name)))?;
|
||||
let power = interpolate(&r.history_times, &h.power, time);
|
||||
let flux = power.map(|q| q / area).filter(|q| q.is_finite());
|
||||
output.push(FaceHeat { body: name.into(), face: h.name.clone(), index, area, power, flux });
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// One optional value per displayed surface triangle. Imported/unrecorded faces remain plain.
|
||||
pub fn triangle_values(faces: &[FaceHeat], base: &DisplayMesh, bodies: &[&str]) -> Vec<Option<f64>> {
|
||||
let by_face: BTreeMap<_, _> = faces.iter().map(|f| ((f.body.as_str(), f.index), f.flux)).collect();
|
||||
base.surface_tris
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let body = bodies.get(t.body as usize)?;
|
||||
by_face.get(&(*body, t.face)).copied().flatten()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn wall_heat_uses_face_identity_area_sign_and_frame_in_both_views() {
|
||||
let mut session = crate::flow::tests::plate_in_a_tank();
|
||||
let crate::StepResult::Fluid(mut r) = session.latest_result("Flow").unwrap().0.as_ref().clone() else { panic!() };
|
||||
let names: Vec<String> = session.design.as_ref().unwrap().bodies[0].faces().iter().map(|f| f.name.clone()).collect();
|
||||
let end = r.frames.last().unwrap().time;
|
||||
r.history_times = vec![end * 0.5, end];
|
||||
r.heat = names
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(k, name)| fieldcraft_lbm::HeatHistory {
|
||||
name: name.clone(),
|
||||
body: Some("Body1".into()),
|
||||
power: vec![0.0, if k == 0 { -120.0 } else { 0.0 }],
|
||||
})
|
||||
.collect();
|
||||
for f in &mut r.frames {
|
||||
f.temperature = vec![20.0; r.grid.len()];
|
||||
}
|
||||
let face_values = faces(&session, &r, Pick::default()).unwrap();
|
||||
assert!(face_values.iter().all(|f| f.area > 0.0));
|
||||
assert!(face_values.iter().any(|f| (f.area - 400.0).abs() < 1e-8), "40 by 10 mm outer face");
|
||||
assert!(face_values.iter().any(|f| (f.area - 300.0).abs() < 1e-8), "30 by 10 mm outer face");
|
||||
assert!(face_values[0].flux.unwrap() < 0.0);
|
||||
assert!((face_values.iter().map(|f| f.flux.unwrap() * f.area).sum::<f64>() + 120.0).abs() < 1e-10);
|
||||
assert!(face_values[1..].iter().all(|f| f.flux == Some(0.0)), "adiabatic faces are zero");
|
||||
session.store_result(crate::StepResult::Fluid(r));
|
||||
let report = session.execute("results.surface", &json!({"field": "wall_heat_flux"})).unwrap();
|
||||
assert_eq!(report["wall_heat"]["positive"], "into wall");
|
||||
assert_eq!(report["wall_heat"]["faces"][0]["power"], -120.0);
|
||||
assert!(report["slice"].is_null(), "wall heat is a surface-only field");
|
||||
session.execute("results.streamlines", &json!({"show": false})).unwrap();
|
||||
let (scene, _, shown) = crate::view::scene(&session, &crate::view::Palette::LIGHT);
|
||||
assert!(shown.contour.as_ref().is_some_and(|c| c.0 == "wall_heat_flux"));
|
||||
assert!(scene.tris.windows(2).any(|t| t[0].color != t[1].color));
|
||||
let report = session.execute("results.frame", &json!({"frame": 0})).unwrap();
|
||||
assert!(report["wall_heat"]["faces"].as_array().unwrap().iter().all(|f| f["flux"].is_null()));
|
||||
assert!(session.execute("results.slice", &json!({"field": "wall_heat_flux"})).is_err());
|
||||
assert!(session.execute("results.streamlines", &json!({"field": "wall_heat_flux"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn histories_interpolate_without_fabricating_initial_heat() {
|
||||
assert_eq!(interpolate(&[1.0, 3.0], &[-4.0, 8.0], 2.0), Some(2.0));
|
||||
assert_eq!(interpolate(&[1.0, 3.0], &[-4.0, 8.0], 1.0), Some(-4.0));
|
||||
assert_eq!(interpolate(&[1.0, 3.0], &[-4.0, 8.0], 0.0), None);
|
||||
assert_eq!(interpolate(&[1.0, 3.0], &[-4.0, 8.0], 4.0), None);
|
||||
assert_eq!(interpolate(&[1.0, 1.0], &[0.0, 1.0], 1.0), None);
|
||||
assert_eq!(interpolate(&[1.0], &[f64::NAN], 1.0), None);
|
||||
assert_eq!(interpolate(&[1.0], &[], 1.0), None);
|
||||
}
|
||||
}
|
||||
@@ -91,8 +91,6 @@ pub enum Field {
|
||||
Temperature,
|
||||
/// Heat flux magnitude (heat transfer steps, at the end of the step).
|
||||
HeatFlux,
|
||||
/// Signed fluid heat received by an obstacle face per geometric area.
|
||||
WallHeatFlux,
|
||||
/// Contact pressure (nonlinear static steps with contact: 0 off the contacts' secondary
|
||||
/// surfaces).
|
||||
ContactPressure,
|
||||
@@ -108,7 +106,7 @@ pub enum Field {
|
||||
}
|
||||
|
||||
impl Field {
|
||||
pub const ALL: [Field; 24] = [
|
||||
pub const ALL: [Field; 23] = [
|
||||
Field::Displacement,
|
||||
Field::DisplacementX,
|
||||
Field::DisplacementY,
|
||||
@@ -127,7 +125,6 @@ impl Field {
|
||||
Field::StressYz,
|
||||
Field::Temperature,
|
||||
Field::HeatFlux,
|
||||
Field::WallHeatFlux,
|
||||
Field::ContactPressure,
|
||||
Field::ContactGap,
|
||||
Field::ContactStatus,
|
||||
@@ -156,7 +153,6 @@ impl Field {
|
||||
Field::StressYz => "stress_yz",
|
||||
Field::Temperature => "temperature",
|
||||
Field::HeatFlux => "heat_flux",
|
||||
Field::WallHeatFlux => "wall_heat_flux",
|
||||
Field::ContactPressure => "contact_pressure",
|
||||
Field::ContactGap => "contact_gap",
|
||||
Field::ContactStatus => "contact_status",
|
||||
@@ -185,7 +181,6 @@ impl Field {
|
||||
Field::StressYz => "Stress YZ",
|
||||
Field::Temperature => "Temperature",
|
||||
Field::HeatFlux => "Heat flux magnitude",
|
||||
Field::WallHeatFlux => "Wall heat flux (into wall)",
|
||||
Field::ContactPressure => "Contact pressure",
|
||||
Field::ContactGap => "Contact gap",
|
||||
Field::ContactStatus => "Contact status (0 open, 1 closed or stick, 2 slip)",
|
||||
@@ -201,7 +196,7 @@ impl Field {
|
||||
Field::Velocity => Quantity::Velocity,
|
||||
Field::Pressure => Quantity::Stress,
|
||||
Field::Temperature => Quantity::Temperature,
|
||||
Field::HeatFlux | Field::WallHeatFlux => Quantity::HeatFlux,
|
||||
Field::HeatFlux => Quantity::HeatFlux,
|
||||
Field::ContactGap | Field::ContactStatus => Quantity::None,
|
||||
Field::ContactPressure
|
||||
| Field::ContactShear
|
||||
@@ -254,7 +249,6 @@ impl Field {
|
||||
| Field::Pressure
|
||||
| Field::Temperature
|
||||
| Field::HeatFlux
|
||||
| Field::WallHeatFlux
|
||||
) && !self.is_contact()
|
||||
}
|
||||
|
||||
@@ -279,16 +273,7 @@ impl Field {
|
||||
e.frames.last().map_or((false, false, false, false), |f| (!f.stress.is_empty(), !f.velocity.is_empty(), false, false))
|
||||
}
|
||||
// Cell fields, drawn by `crate::flow`.
|
||||
Kind::Fluid(f) => {
|
||||
let mut fields = vec![Field::Velocity, Field::Pressure];
|
||||
if !f.frames.is_empty() && f.frames.iter().all(|frame| !frame.temperature.is_empty() && frame.temperature.len() == f.grid.len()) {
|
||||
fields.push(Field::Temperature);
|
||||
if f.heat.iter().any(|h| h.body.is_some()) {
|
||||
fields.push(Field::WallHeatFlux);
|
||||
}
|
||||
}
|
||||
return fields.into_iter();
|
||||
}
|
||||
Kind::Fluid(_) => return vec![Field::Velocity, Field::Pressure].into_iter(),
|
||||
Kind::Heat(_) => return Field::ALL.into_iter().filter(|f| f.is_thermal()).collect::<Vec<_>>().into_iter(),
|
||||
};
|
||||
Field::ALL
|
||||
@@ -296,7 +281,6 @@ impl Field {
|
||||
.filter(move |f| match f {
|
||||
Field::Velocity => velocity,
|
||||
Field::Pressure => false,
|
||||
Field::WallHeatFlux => false,
|
||||
Field::Temperature => temperature,
|
||||
Field::HeatFlux => flux,
|
||||
f if f.is_contact() => contact,
|
||||
@@ -552,7 +536,7 @@ pub fn field_values(model: &Model, r: &StepResult, field: Field, pick: Pick) ->
|
||||
Kind::Static(_) | Kind::Frequency(_) | Kind::Fluid(_) | Kind::Heat(_) => Err(unavailable()),
|
||||
},
|
||||
// Per cell (crate::flow::cell_values), not per node.
|
||||
Field::Pressure | Field::WallHeatFlux => Err(unavailable()),
|
||||
Field::Pressure => Err(unavailable()),
|
||||
Field::Temperature => match r.kind() {
|
||||
Kind::Heat(h) => Ok(heat_frame(h, pick)?.1.to_vec()),
|
||||
Kind::Static(StaticResult { temperature: Some(t), .. }) => Ok(t.clone()),
|
||||
@@ -722,30 +706,6 @@ impl Session {
|
||||
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)
|
||||
&& let Ok(faces) = crate::flow::wall::faces(self, f, v.pick())
|
||||
{
|
||||
let values: Vec<f64> = faces.iter().map(|f| f.flux.unwrap_or(f64::NAN)).collect();
|
||||
let end = |(k, value): (usize, f64)| json!({"body": faces.get(k).map(|f| &f.body), "face": faces.get(k).map(|f| &f.face), "value": value});
|
||||
let range = crate::flow::cell_range(&values).map(|(a, b)| json!({"min": end(a), "max": end(b)}));
|
||||
out["contour"] = json!({"field": Field::WallHeatFlux, "label": Field::WallHeatFlux.label(), "unit": Quantity::HeatFlux.unit(self.model.units()), "range": range});
|
||||
if v.range_min.is_some() || v.range_max.is_some() {
|
||||
out["contour"]["legend"] = json!({"min": v.range_min, "max": v.range_max});
|
||||
}
|
||||
out["wall_heat"] = json!({"faces": faces, "positive": "into wall", "history": "linear interpolation between recorded samples; no extrapolation", "unrecorded": "uncoloured"});
|
||||
}
|
||||
}
|
||||
Kind::Heat(h) => {
|
||||
out["heat_flows"] = heat_flows(h);
|
||||
@@ -813,7 +773,7 @@ mod tests {
|
||||
}
|
||||
let (modes, _) = s.latest_result("Modes").unwrap();
|
||||
assert_eq!(Field::available(modes).count(), 4);
|
||||
assert_eq!(Field::available(bend).count(), Field::ALL.len() - 10, "all but velocity, pressure, the thermal and the contact fields");
|
||||
assert_eq!(Field::available(bend).count(), Field::ALL.len() - 9, "all but velocity, pressure, the thermal and the contact fields");
|
||||
assert!(field_values(&s.model, modes, Field::VonMises, Pick { mode: 1, frame: None }).is_err());
|
||||
assert!(field_values(&s.model, modes, Field::Displacement, Pick { mode: 9, frame: None }).is_err());
|
||||
assert_eq!(field_values(&s.model, modes, Field::Displacement, Pick { mode: 2, frame: None }).unwrap().len(), modes.nodes().len());
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
use fieldcraft_geometry::Shape;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
mod heated_flow;
|
||||
|
||||
/// A sample the app offers (Help › Samples, the empty viewport, `file.sample`, `--sample NAME`).
|
||||
pub struct Sample {
|
||||
pub name: &'static str,
|
||||
@@ -94,14 +92,6 @@ pub const SAMPLES: &[Sample] = &[
|
||||
prepare: None,
|
||||
script: |_| flow_past(),
|
||||
},
|
||||
Sample {
|
||||
name: "heated-flow",
|
||||
label: "Heated Flow Past a Bracket",
|
||||
about: "Cool air passing an 80 °C bracket: temperature slices and signed wall heat, a small transient demonstration",
|
||||
design: Some(UPRIGHT_BRACKET),
|
||||
prepare: None,
|
||||
script: heated_flow::script,
|
||||
},
|
||||
Sample {
|
||||
name: "heated-bracket",
|
||||
label: "Heated Bracket",
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
//! A small transient heated-flow demonstration using the original upright bracket.
|
||||
//! Air at 20 °C passes an 80 °C bracket; no buoyancy or conjugate solid conduction.
|
||||
|
||||
use fieldcraft_geometry::Shape;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub(super) fn script(bodies: &[Shape]) -> Value {
|
||||
let walls: Vec<Value> = bodies
|
||||
.iter()
|
||||
.filter(|b| b.name == "Bracket")
|
||||
.flat_map(|b| {
|
||||
b.faces().iter().map(move |f| {
|
||||
json!({
|
||||
"face": {"body": b.name, "face": f.name},
|
||||
"condition": {"type": "temperature", "value": 80.0}
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let [density, viscosity] = super::AIR;
|
||||
json!([
|
||||
{"command": "model.units", "params": {"system": "mm-t-s-N-MPa"}},
|
||||
{"command": "model.material.create", "params": {"name": "Air", "density": density,
|
||||
"fluid": {"type": "newtonian", "viscosity": viscosity},
|
||||
"thermal": {"conductivity": 0.026, "specific_heat": 1.005e9}}},
|
||||
{"command": "model.fluid_domain.create", "params": {
|
||||
"name": "WarmAir", "material": "Air", "min": [-30, -20, 0], "max": [100, 60, 70], "spacing": 5,
|
||||
"faces": {"x_min": {"type": "velocity", "velocity": [1, 0, 0]}, "x_max": {"type": "pressure", "pressure": 0},
|
||||
"y_min": {"type": "slip"}, "y_max": {"type": "slip"}, "z_min": {"type": "wall"}, "z_max": {"type": "slip"}},
|
||||
"obstacles": ["Bracket"], "initial_velocity": [1, 0, 0],
|
||||
"thermal": {"initial_temperature": 20, "surfaces": walls,
|
||||
"faces": {"x_min": {"type": "temperature", "value": 20}}}
|
||||
}},
|
||||
{"command": "model.step.create", "params": {"name": "HeatedFlow", "procedure": {
|
||||
"type": "fluid", "domain": "WarmAir", "steps": 180, "frames": 6
|
||||
}}}
|
||||
])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn heated_flow_sample_solves_and_exposes_temperature_and_wall_heat() {
|
||||
let mut s = crate::Session::default();
|
||||
s.execute("file.sample", &json!({"name": "heated-flow"})).unwrap();
|
||||
s.execute("solve.fluid", &json!({"backend": "cpu"})).unwrap();
|
||||
let report = s.execute("results.slice", &json!({"field": "temperature"})).unwrap();
|
||||
let max = report["contour"]["range"]["max"]["value"].as_f64().unwrap();
|
||||
let min = report["contour"]["range"]["min"]["value"].as_f64().unwrap();
|
||||
assert!(max > 20.1 && max <= 80.01 && min >= 19.99, "{report}");
|
||||
let report = s.execute("results.surface", &json!({"field": "wall_heat_flux"})).unwrap();
|
||||
assert_eq!(report["contour"]["unit"], "mW/mm²");
|
||||
assert!(report["wall_heat"]["faces"].as_array().unwrap().iter().any(|f| f["power"].as_f64().unwrap_or(0.0) < 0.0));
|
||||
assert_eq!(report["frames"].as_array().unwrap().len(), 7);
|
||||
}
|
||||
}
|
||||
@@ -511,8 +511,7 @@ fn picture(s: &Session, p: &Palette, section: Option<Section>, toward: Vec3) ->
|
||||
// The part as it is, with the flow drawn through and around it.
|
||||
let mut scene = model_scene(&base, p);
|
||||
let mut bounds = base.bounds;
|
||||
let names: Vec<&str> = bodies.iter().map(String::as_str).collect();
|
||||
if let Ok(fp) = crate::flow::plot_with_surfaces(view, result, &s.traced, s, &base, &names) {
|
||||
if let Ok(fp) = crate::flow::plot(view, result, &s.traced) {
|
||||
if let Some(c) = &fp.contour {
|
||||
let cell = |(k, v): (usize, f64)| (u32::try_from(k).unwrap_or(u32::MAX), v);
|
||||
shown.contour = Some((c.field.id().to_string(), cell(c.min), cell(c.max)));
|
||||
|
||||
@@ -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>> {
|
||||
let (Some(outer), Some(hole)) = (loops.first(), loops.get(1)) else { return Vec::new() };
|
||||
let c = centroid(&hole.pts);
|
||||
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) && region::is_reflex(p.angle(i));
|
||||
let corner = |p: &Poly, i: usize| p.is_end(i) && p.angle(i) <= 0.75 * std::f64::consts::PI;
|
||||
let concave = |p: &Poly, i: usize| p.is_end(i) && p.angle(i) >= 1.25 * std::f64::consts::PI;
|
||||
// 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 o = loops.get(l)?.at(i);
|
||||
@@ -1134,9 +1134,8 @@ mod tests {
|
||||
let n = ((a1 - a0).abs() / 5.0).ceil().max(1.0) as usize;
|
||||
(0..=n)
|
||||
.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();
|
||||
[c[0] + r * libm::cos(t), c[1] + r * libm::sin(t)]
|
||||
[c[0] + r * t.cos(), c[1] + r * t.sin()]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -1309,10 +1308,6 @@ mod tests {
|
||||
]);
|
||||
let m = mesh_profile(&profile, &[], 2.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]
|
||||
|
||||
@@ -47,24 +47,6 @@ const MAX_ANNULUS_NODES: usize = 4096;
|
||||
/// Smoothing sweeps over the interior of a triangle primitive.
|
||||
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)]
|
||||
pub(crate) enum Scheme {
|
||||
/// Corner positions (curve uses) in the single loop.
|
||||
@@ -107,10 +89,10 @@ pub(crate) fn classify(loops: &[Vec<Vec<Q>>]) -> Scheme {
|
||||
match loops {
|
||||
[lp] => {
|
||||
let Some(a) = angles(lp, ccw(lp)) else { return Scheme::Free };
|
||||
if a.iter().any(|&x| is_reflex(x)) {
|
||||
if a.iter().any(|&x| x >= 1.25 * PI) {
|
||||
return Scheme::Free;
|
||||
}
|
||||
let corners: Vec<usize> = (0..a.len()).filter(|&k| a.get(k).is_some_and(|&x| is_corner(x))).collect();
|
||||
let corners: Vec<usize> = (0..a.len()).filter(|&k| a.get(k).is_some_and(|&x| x <= 0.75 * PI)).collect();
|
||||
if corners.is_empty() {
|
||||
return Scheme::Disk;
|
||||
}
|
||||
@@ -121,7 +103,7 @@ pub(crate) fn classify(loops: &[Vec<Vec<Q>>]) -> Scheme {
|
||||
}
|
||||
}
|
||||
[outer, inner] => {
|
||||
let smooth = |lp: &Vec<Vec<Q>>| angles(lp, ccw(lp)).is_some_and(|a| a.iter().all(|&x| !is_corner(x) && !is_reflex(x)));
|
||||
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));
|
||||
if smooth(outer) && smooth(inner) { Scheme::Annulus } else { Scheme::Free }
|
||||
}
|
||||
_ => Scheme::Free,
|
||||
@@ -586,30 +568,6 @@ mod tests {
|
||||
use super::*;
|
||||
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> {
|
||||
(0..n)
|
||||
.map(|k| {
|
||||
|
||||
@@ -1,52 +1,9 @@
|
||||
//! Units: the unit system label a model carries today, and the SI unit table every conversion
|
||||
//! goes through.
|
||||
//! The unit system label.
|
||||
//!
|
||||
//! **The table** ([`Dimension`], [`Unit`], [`Quantity`], [`DisplaySystem`]) is the one place
|
||||
//! FieldCraft writes down a conversion factor. Each [`Quantity`] has a dimension and an SI unit;
|
||||
//! a [`Unit`] parses expressions such as `MPa`, `t/mm³` or `W/(m·K)` to a dimension and a factor
|
||||
//! to SI; [`Quantity::parse`] reads `"210 GPa"` or `"20 °C"` with a dimension check, and
|
||||
//! [`Quantity::format`] shows an SI value in a [`DisplaySystem`]. Absolute temperatures
|
||||
//! ([`Quantity::Temperature`]) take the °C/°F offset; temperature differences and every compound
|
||||
//! unit never do. Issue #579 moves model storage to SI on top of this table; until then the
|
||||
//! model still stores numbers as entered.
|
||||
//!
|
||||
//! **The label** ([`UnitSystem`]): the model never converts yet: numbers are stored as entered.
|
||||
//! The label records which consistent system they are in, so the check can warn when a magnitude
|
||||
//! is implausible for it (a steel modulus of 210 000 in a metre–pascal model, a density of 7850
|
||||
//! in a millimetre–tonne one), and so writers can say which system a deck is in.
|
||||
|
||||
mod dimension;
|
||||
mod display;
|
||||
mod quantity;
|
||||
mod unit;
|
||||
|
||||
pub use dimension::Dimension;
|
||||
pub use display::{DisplaySystem, Preset};
|
||||
pub use quantity::{Quantity, ValueInput, WithUnit, format_number};
|
||||
pub use unit::Unit;
|
||||
|
||||
/// Why a unit or a value with a unit was refused.
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
pub enum UnitError {
|
||||
#[error("no value or unit given")]
|
||||
Empty,
|
||||
#[error("unit text is too long")]
|
||||
TooLong,
|
||||
#[error("`{0}` is not a finite number")]
|
||||
NotFinite(String),
|
||||
#[error("unknown unit `{0}`")]
|
||||
UnknownUnit(String),
|
||||
#[error("cannot read `{0}` as a value with a unit (for example `210 GPa`, `t/mm³`, `W/(m·K)`)")]
|
||||
Syntax(String),
|
||||
#[error("`{0}` is out of range")]
|
||||
OutOfRange(String),
|
||||
#[error("`{0}` is below absolute zero")]
|
||||
BelowAbsoluteZero(String),
|
||||
#[error("`{unit}` is not a unit of {quantity} (angles take rad, deg or °; ratios take 1 or %)")]
|
||||
WrongKind { unit: String, quantity: &'static str },
|
||||
#[error("`{unit}` is {found}, but a {quantity} is {expected}")]
|
||||
WrongDimension { unit: String, found: Dimension, quantity: &'static str, expected: Dimension },
|
||||
}
|
||||
//! The model never converts: numbers are stored as entered. The label records which consistent
|
||||
//! system they are in, so the check can warn when a magnitude is implausible for it (a steel
|
||||
//! modulus of 210 000 in a metre–pascal model, a density of 7850 in a millimetre–tonne one), and
|
||||
//! so writers can say which system a deck is in.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
//! Physical dimensions as integer exponents of the SI base dimensions.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// A physical dimension: the exponents of length L, mass M, time T, temperature Θ and electric
|
||||
/// current I. Dimensionless quantities (Poisson's ratio, strain, angles) have every exponent 0.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
|
||||
pub struct Dimension {
|
||||
pub length: i8,
|
||||
pub mass: i8,
|
||||
pub time: i8,
|
||||
pub temperature: i8,
|
||||
pub current: i8,
|
||||
}
|
||||
|
||||
impl Dimension {
|
||||
pub const NONE: Dimension = Dimension::new(0, 0, 0, 0);
|
||||
pub const LENGTH: Dimension = Dimension::new(1, 0, 0, 0);
|
||||
pub const MASS: Dimension = Dimension::new(0, 1, 0, 0);
|
||||
pub const TIME: Dimension = Dimension::new(0, 0, 1, 0);
|
||||
pub const TEMPERATURE: Dimension = Dimension::new(0, 0, 0, 1);
|
||||
pub const CURRENT: Dimension = Dimension { current: 1, ..Dimension::NONE };
|
||||
|
||||
/// L^length M^mass T^time Θ^temperature (no current: FieldCraft has no electrical quantities yet).
|
||||
pub const fn new(length: i8, mass: i8, time: i8, temperature: i8) -> Dimension {
|
||||
Dimension { length, mass, time, temperature, current: 0 }
|
||||
}
|
||||
|
||||
fn exponents(self) -> [i8; 5] {
|
||||
[self.length, self.mass, self.time, self.temperature, self.current]
|
||||
}
|
||||
|
||||
fn from_exponents([length, mass, time, temperature, current]: [i8; 5]) -> Dimension {
|
||||
Dimension { length, mass, time, temperature, current }
|
||||
}
|
||||
|
||||
fn zip(self, other: Dimension, f: impl Fn(i8, i8) -> Option<i8>) -> Option<Dimension> {
|
||||
let (a, b) = (self.exponents(), other.exponents());
|
||||
let mut out = [0i8; 5];
|
||||
for ((o, x), y) in out.iter_mut().zip(a).zip(b) {
|
||||
*o = f(x, y)?;
|
||||
}
|
||||
Some(Dimension::from_exponents(out))
|
||||
}
|
||||
|
||||
/// The dimension of a product, or `None` if an exponent overflows.
|
||||
pub fn checked_mul(self, other: Dimension) -> Option<Dimension> {
|
||||
self.zip(other, i8::checked_add)
|
||||
}
|
||||
|
||||
/// The dimension of a quotient, or `None` if an exponent overflows.
|
||||
pub fn checked_div(self, other: Dimension) -> Option<Dimension> {
|
||||
self.zip(other, i8::checked_sub)
|
||||
}
|
||||
|
||||
/// The dimension raised to an integer power, or `None` if an exponent overflows.
|
||||
pub fn checked_pow(self, power: i8) -> Option<Dimension> {
|
||||
self.zip(Dimension::NONE, |x, _| x.checked_mul(power))
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Dimension {
|
||||
/// `L M T^-2` style; `1` for dimensionless.
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let mut first = true;
|
||||
for (symbol, e) in ["L", "M", "T", "Θ", "I"].into_iter().zip(self.exponents()) {
|
||||
if e == 0 {
|
||||
continue;
|
||||
}
|
||||
if !first {
|
||||
f.write_str(" ")?;
|
||||
}
|
||||
first = false;
|
||||
if e == 1 { write!(f, "{symbol}")? } else { write!(f, "{symbol}^{e}")? }
|
||||
}
|
||||
if first { f.write_str("1") } else { Ok(()) }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn arithmetic_and_display() {
|
||||
let force = Dimension::new(1, 1, -2, 0);
|
||||
let area = Dimension::LENGTH.checked_pow(2).unwrap();
|
||||
let pressure = force.checked_div(area).unwrap();
|
||||
assert_eq!(pressure, Dimension::new(-1, 1, -2, 0));
|
||||
assert_eq!(pressure.to_string(), "L^-1 M T^-2");
|
||||
assert_eq!(Dimension::NONE.to_string(), "1");
|
||||
assert_eq!(pressure.checked_mul(area), Some(force));
|
||||
assert_eq!(Dimension::new(100, 0, 0, 0).checked_pow(2), None);
|
||||
assert_eq!(Dimension::new(127, 0, 0, 0).checked_mul(Dimension::LENGTH), None);
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
//! Display systems: which unit each quantity is shown and typed in. Choosing one never changes a
|
||||
//! stored value.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{Quantity, Unit, UnitError};
|
||||
|
||||
/// A consistent set of display units.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum Preset {
|
||||
/// Strict SI: m, kg, s, N, Pa, K.
|
||||
#[serde(rename = "SI")]
|
||||
Si,
|
||||
/// SI with temperatures in °C and angles in degrees.
|
||||
#[serde(rename = "m-kg-s")]
|
||||
MKgS,
|
||||
/// Millimetre, tonne, second, newton, megapascal, °C: what most structural CAE users expect.
|
||||
#[default]
|
||||
#[serde(rename = "mm-t-s-N-MPa")]
|
||||
MmTonneS,
|
||||
/// Inch, lbf·s²/in, second, pound-force, psi, °F.
|
||||
#[serde(rename = "in-lbf-s-psi")]
|
||||
InLbfS,
|
||||
}
|
||||
|
||||
impl Preset {
|
||||
pub const ALL: [Preset; 4] = [Preset::Si, Preset::MKgS, Preset::MmTonneS, Preset::InLbfS];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Preset::Si => "SI",
|
||||
Preset::MKgS => "m-kg-s",
|
||||
Preset::MmTonneS => "mm-t-s-N-MPa",
|
||||
Preset::InLbfS => "in-lbf-s-psi",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_label(label: &str) -> Option<Preset> {
|
||||
Preset::ALL.into_iter().find(|p| p.label() == label)
|
||||
}
|
||||
|
||||
/// The unit this preset shows `q` in.
|
||||
pub fn symbol(self, q: Quantity) -> &'static str {
|
||||
use Quantity as Q;
|
||||
match self {
|
||||
Preset::Si => q.si_symbol(),
|
||||
Preset::MKgS => match q {
|
||||
Q::Temperature => "°C",
|
||||
Q::Angle => "deg",
|
||||
_ => q.si_symbol(),
|
||||
},
|
||||
Preset::MmTonneS => match q {
|
||||
Q::Dimensionless => "1",
|
||||
Q::Angle => "deg",
|
||||
Q::Length => "mm",
|
||||
Q::Area => "mm²",
|
||||
Q::Volume => "mm³",
|
||||
Q::SecondMomentOfArea => "mm⁴",
|
||||
Q::Mass => "t",
|
||||
Q::Density => "t/mm³",
|
||||
Q::Time => "s",
|
||||
Q::Frequency => "Hz",
|
||||
Q::Velocity => "mm/s",
|
||||
Q::Acceleration => "mm/s²",
|
||||
Q::Force => "N",
|
||||
Q::Moment => "N·mm",
|
||||
Q::Pressure => "MPa",
|
||||
Q::Stiffness => "N/mm",
|
||||
Q::Damping => "N·s/mm",
|
||||
Q::Energy => "mJ",
|
||||
Q::Power => "mW",
|
||||
Q::DynamicViscosity => "MPa·s",
|
||||
Q::KinematicViscosity => "mm²/s",
|
||||
Q::Temperature => "°C",
|
||||
Q::TemperatureDifference => "K",
|
||||
Q::ThermalExpansion => "K⁻¹",
|
||||
Q::ThermalConductivity => "mW/(mm·K)",
|
||||
Q::SpecificHeat => "mJ/(t·K)",
|
||||
Q::HeatTransferCoefficient => "mW/(mm²·K)",
|
||||
Q::HeatFlux => "mW/mm²",
|
||||
Q::HeatGeneration => "mW/mm³",
|
||||
},
|
||||
Preset::InLbfS => match q {
|
||||
Q::Dimensionless => "1",
|
||||
Q::Angle => "deg",
|
||||
Q::Length => "in",
|
||||
Q::Area => "in²",
|
||||
Q::Volume => "in³",
|
||||
Q::SecondMomentOfArea => "in⁴",
|
||||
Q::Mass => "lbf·s²/in",
|
||||
Q::Density => "lbf·s²/in⁴",
|
||||
Q::Time => "s",
|
||||
Q::Frequency => "Hz",
|
||||
Q::Velocity => "in/s",
|
||||
Q::Acceleration => "in/s²",
|
||||
Q::Force => "lbf",
|
||||
Q::Moment => "lbf·in",
|
||||
Q::Pressure => "psi",
|
||||
Q::Stiffness => "lbf/in",
|
||||
Q::Damping => "lbf·s/in",
|
||||
Q::Energy => "in·lbf",
|
||||
Q::Power => "in·lbf/s",
|
||||
Q::DynamicViscosity => "psi·s",
|
||||
Q::KinematicViscosity => "in²/s",
|
||||
Q::Temperature => "°F",
|
||||
Q::TemperatureDifference => "°F",
|
||||
Q::ThermalExpansion => "°F⁻¹",
|
||||
Q::ThermalConductivity => "lbf/(s·°F)",
|
||||
Q::SpecificHeat => "in²/(s²·°F)",
|
||||
Q::HeatTransferCoefficient => "lbf/(in·s·°F)",
|
||||
Q::HeatFlux => "lbf/(in·s)",
|
||||
Q::HeatGeneration => "lbf/(in²·s)",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The units values are shown and typed in: a preset with optional per-quantity overrides (say
|
||||
/// mm-t-s-N-MPa with energies in J).
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct DisplaySystem {
|
||||
preset: Preset,
|
||||
overrides: Vec<(Quantity, Unit)>,
|
||||
}
|
||||
|
||||
impl DisplaySystem {
|
||||
pub fn new(preset: Preset) -> DisplaySystem {
|
||||
DisplaySystem { preset, overrides: Vec::new() }
|
||||
}
|
||||
|
||||
pub fn preset(&self) -> Preset {
|
||||
self.preset
|
||||
}
|
||||
|
||||
/// Shows `q` in `unit` instead of the preset's unit; refused when the dimension differs.
|
||||
pub fn with_override(mut self, q: Quantity, unit: &str) -> Result<DisplaySystem, UnitError> {
|
||||
let unit = Unit::parse(unit)?;
|
||||
q.express(0.0, &unit)?;
|
||||
self.overrides.retain(|(o, _)| *o != q);
|
||||
self.overrides.push((q, unit));
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// The unit `q` is shown and typed in.
|
||||
pub fn unit(&self, q: Quantity) -> Result<Unit, UnitError> {
|
||||
match self.overrides.iter().find(|(o, _)| *o == q) {
|
||||
Some((_, u)) => Ok(u.clone()),
|
||||
None => Unit::parse(self.preset.symbol(q)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
//! The kinds of physical quantity FieldCraft stores, and parsing and formatting of values with
|
||||
//! units ("210 GPa", `{"value": 20, "unit": "°C"}`) against them.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::unit::MAX_TEXT;
|
||||
use super::{Dimension, DisplaySystem, Unit, UnitError};
|
||||
|
||||
macro_rules! quantities {
|
||||
($($variant:ident = $name:literal, $dim:expr, $si:literal;)*) => {
|
||||
/// A kind of physical quantity. Each has one dimension and one SI unit, which is how it is
|
||||
/// stored.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Quantity {
|
||||
$($variant,)*
|
||||
}
|
||||
|
||||
impl Quantity {
|
||||
pub const ALL: &[Quantity] = &[$(Quantity::$variant,)*];
|
||||
|
||||
/// The name used in commands and files (`thermal_conductivity`, …).
|
||||
pub fn name(self) -> &'static str {
|
||||
match self { $(Quantity::$variant => $name,)* }
|
||||
}
|
||||
|
||||
pub fn dimension(self) -> Dimension {
|
||||
match self { $(Quantity::$variant => $dim,)* }
|
||||
}
|
||||
|
||||
/// The SI unit the quantity is stored in.
|
||||
pub fn si_symbol(self) -> &'static str {
|
||||
match self { $(Quantity::$variant => $si,)* }
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const fn d(length: i8, mass: i8, time: i8, temperature: i8) -> Dimension {
|
||||
Dimension::new(length, mass, time, temperature)
|
||||
}
|
||||
|
||||
quantities! {
|
||||
Dimensionless = "dimensionless", d(0, 0, 0, 0), "1";
|
||||
Angle = "angle", d(0, 0, 0, 0), "rad";
|
||||
Length = "length", d(1, 0, 0, 0), "m";
|
||||
Area = "area", d(2, 0, 0, 0), "m²";
|
||||
Volume = "volume", d(3, 0, 0, 0), "m³";
|
||||
SecondMomentOfArea = "second_moment_of_area", d(4, 0, 0, 0), "m⁴";
|
||||
Mass = "mass", d(0, 1, 0, 0), "kg";
|
||||
Density = "density", d(-3, 1, 0, 0), "kg/m³";
|
||||
Time = "time", d(0, 0, 1, 0), "s";
|
||||
Frequency = "frequency", d(0, 0, -1, 0), "Hz";
|
||||
Velocity = "velocity", d(1, 0, -1, 0), "m/s";
|
||||
Acceleration = "acceleration", d(1, 0, -2, 0), "m/s²";
|
||||
Force = "force", d(1, 1, -2, 0), "N";
|
||||
Moment = "moment", d(2, 1, -2, 0), "N·m";
|
||||
Pressure = "pressure", d(-1, 1, -2, 0), "Pa";
|
||||
Stiffness = "stiffness", d(0, 1, -2, 0), "N/m";
|
||||
Damping = "damping", d(0, 1, -1, 0), "N·s/m";
|
||||
Energy = "energy", d(2, 1, -2, 0), "J";
|
||||
Power = "power", d(2, 1, -3, 0), "W";
|
||||
DynamicViscosity = "dynamic_viscosity", d(-1, 1, -1, 0), "Pa·s";
|
||||
KinematicViscosity = "kinematic_viscosity", d(2, 0, -1, 0), "m²/s";
|
||||
Temperature = "temperature", d(0, 0, 0, 1), "K";
|
||||
TemperatureDifference = "temperature_difference", d(0, 0, 0, 1), "K";
|
||||
ThermalExpansion = "thermal_expansion", d(0, 0, 0, -1), "K⁻¹";
|
||||
ThermalConductivity = "thermal_conductivity", d(1, 1, -3, -1), "W/(m·K)";
|
||||
SpecificHeat = "specific_heat", d(2, 0, -2, -1), "J/(kg·K)";
|
||||
HeatTransferCoefficient = "heat_transfer_coefficient", d(0, 1, -3, -1), "W/(m²·K)";
|
||||
HeatFlux = "heat_flux", d(0, 1, -3, 0), "W/m²";
|
||||
HeatGeneration = "heat_generation", d(-1, 1, -3, 0), "W/m³";
|
||||
}
|
||||
|
||||
impl Quantity {
|
||||
pub fn from_name(name: &str) -> Option<Quantity> {
|
||||
Quantity::ALL.iter().copied().find(|q| q.name() == name)
|
||||
}
|
||||
|
||||
/// Whether the unit's zero offset applies (°C and °F as absolute temperatures).
|
||||
fn absolute(self) -> bool {
|
||||
self == Quantity::Temperature
|
||||
}
|
||||
|
||||
/// Converts `value` in `unit` to this quantity's SI value, checking the dimension and the
|
||||
/// result.
|
||||
pub fn to_si(self, value: f64, unit: &Unit) -> Result<f64, UnitError> {
|
||||
if !value.is_finite() {
|
||||
return Err(UnitError::NotFinite(value.to_string()));
|
||||
}
|
||||
self.check(unit)?;
|
||||
let si = unit.to_si(value, self.absolute());
|
||||
if !si.is_finite() {
|
||||
return Err(UnitError::OutOfRange(format!("{value} {}", unit.symbol())));
|
||||
}
|
||||
if self.absolute() && si < 0.0 {
|
||||
return Err(UnitError::BelowAbsoluteZero(format!("{value} {}", unit.symbol())));
|
||||
}
|
||||
Ok(si)
|
||||
}
|
||||
|
||||
/// Converts an SI value of this quantity to `unit`.
|
||||
pub fn express(self, si: f64, unit: &Unit) -> Result<f64, UnitError> {
|
||||
self.check(unit)?;
|
||||
let v = unit.express(si, self.absolute());
|
||||
if v.is_finite() { Ok(v) } else { Err(UnitError::NotFinite(v.to_string())) }
|
||||
}
|
||||
|
||||
fn check(self, unit: &Unit) -> Result<(), UnitError> {
|
||||
if unit.dimension() == self.dimension() {
|
||||
// Angles and ratios share the empty dimension: only an angle takes rad, deg or °.
|
||||
if unit.is_angle() == (self == Quantity::Angle) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(UnitError::WrongKind { unit: unit.symbol().to_string(), quantity: self.name() })
|
||||
}
|
||||
} else {
|
||||
Err(UnitError::WrongDimension {
|
||||
unit: unit.symbol().to_string(),
|
||||
found: unit.dimension(),
|
||||
quantity: self.name(),
|
||||
expected: self.dimension(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses `"210 GPa"`, `"25 mm/s"`, `"-5 °C"` (or a bare `"210"`, meaning `display`'s unit)
|
||||
/// to the SI value.
|
||||
pub fn parse(self, text: &str, display: &DisplaySystem) -> Result<f64, UnitError> {
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return Err(UnitError::Empty);
|
||||
}
|
||||
if text.len() > MAX_TEXT {
|
||||
return Err(UnitError::TooLong);
|
||||
}
|
||||
let (number, unit) = split_number(text).ok_or_else(|| UnitError::Syntax(text.to_string()))?;
|
||||
if unit.trim().is_empty() { self.to_si(number, &display.unit(self)?) } else { self.to_si(number, &Unit::parse(unit)?) }
|
||||
}
|
||||
|
||||
/// Formats an SI value in `display`'s unit for this quantity, with the unit: `210000 MPa`.
|
||||
pub fn format(self, si: f64, display: &DisplaySystem) -> Result<String, UnitError> {
|
||||
let unit = display.unit(self)?;
|
||||
let v = self.express(si, &unit)?;
|
||||
Ok(if unit.symbol() == "1" { format_number(v) } else { format!("{} {}", format_number(v), unit.symbol()) })
|
||||
}
|
||||
}
|
||||
|
||||
/// The leading number of `text` and the rest. `inf` and `nan` are not numbers here.
|
||||
fn split_number(text: &str) -> Option<(f64, &str)> {
|
||||
let end = text.find(|c: char| !(c.is_ascii_digit() || matches!(c, '+' | '-' | '.' | 'e' | 'E'))).unwrap_or(text.len());
|
||||
// Shrink until the prefix parses: "2e" in "2em" is not an exponent.
|
||||
(1..=end).rev().find_map(|i| {
|
||||
let head = text.get(..i)?;
|
||||
let v: f64 = head.parse().ok()?;
|
||||
Some((v, text.get(i..)?))
|
||||
})
|
||||
}
|
||||
|
||||
/// A value with six significant digits and no trailing zeros: `210000`, `7.85e-9`, `0.3`.
|
||||
pub fn format_number(v: f64) -> String {
|
||||
if v == 0.0 || !v.is_finite() {
|
||||
return if v == 0.0 { "0".to_string() } else { v.to_string() };
|
||||
}
|
||||
let exp = v.abs().log10().floor();
|
||||
if (-4.0..7.0).contains(&exp) {
|
||||
let decimals = (5.0 - exp).max(0.0) as usize;
|
||||
let s = format!("{v:.decimals$}");
|
||||
if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s }
|
||||
} else {
|
||||
let s = format!("{v:.5e}");
|
||||
match s.split_once('e') {
|
||||
Some((mantissa, e)) => {
|
||||
let m = if mantissa.contains('.') { mantissa.trim_end_matches('0').trim_end_matches('.') } else { mantissa };
|
||||
format!("{m}e{e}")
|
||||
}
|
||||
None => s,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A value as commands, scripts and the MCP give it: a bare number (in the display system), a
|
||||
/// string with its unit (`"210 GPa"`), or `{"value": 210, "unit": "GPa"}`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum ValueInput {
|
||||
Number(f64),
|
||||
Text(String),
|
||||
WithUnit(WithUnit),
|
||||
}
|
||||
|
||||
/// `{"value": 210, "unit": "GPa"}`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WithUnit {
|
||||
pub value: f64,
|
||||
pub unit: String,
|
||||
}
|
||||
|
||||
impl ValueInput {
|
||||
/// The SI value of this input as quantity `q`; a bare number is in `display`'s unit.
|
||||
pub fn to_si(&self, q: Quantity, display: &DisplaySystem) -> Result<f64, UnitError> {
|
||||
match self {
|
||||
ValueInput::Number(v) => q.to_si(*v, &display.unit(q)?),
|
||||
ValueInput::Text(t) => q.parse(t, display),
|
||||
ValueInput::WithUnit(w) => q.to_si(w.value, &Unit::parse(&w.unit)?),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_si_symbol_parses_to_its_dimension_with_factor_one() {
|
||||
for &q in Quantity::ALL {
|
||||
let u = Unit::parse(q.si_symbol()).unwrap_or_else(|e| panic!("{q:?}: {e}"));
|
||||
assert_eq!(u.dimension(), q.dimension(), "{q:?}");
|
||||
assert!((u.factor() - 1.0).abs() < 1e-15, "{q:?}");
|
||||
assert_eq!(Quantity::from_name(q.name()), Some(q));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers_split_from_units() {
|
||||
assert_eq!(split_number("210 GPa"), Some((210.0, " GPa")));
|
||||
assert_eq!(split_number("2.1e11Pa"), Some((2.1e11, "Pa")));
|
||||
assert_eq!(split_number("-5 °C"), Some((-5.0, " °C")));
|
||||
assert_eq!(split_number("3e"), Some((3.0, "e")));
|
||||
assert_eq!(split_number("inf Pa"), None);
|
||||
assert_eq!(split_number("NaN"), None);
|
||||
assert_eq!(split_number("GPa"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers_format_with_six_significant_digits() {
|
||||
assert_eq!(format_number(210000.0), "210000");
|
||||
assert_eq!(format_number(7.85e-9), "7.85e-9");
|
||||
assert_eq!(format_number(0.3), "0.3");
|
||||
assert_eq!(format_number(2.1e11), "2.1e11");
|
||||
assert_eq!(format_number(-20.0), "-20");
|
||||
assert_eq!(format_number(1.0 / 3.0), "0.333333");
|
||||
assert_eq!(format_number(0.0), "0");
|
||||
}
|
||||
}
|
||||
@@ -1,420 +0,0 @@
|
||||
//! Named units and unit expressions (`MPa`, `t/mm³`, `W/(m·K)`), each reduced to a dimension and
|
||||
//! a factor to SI.
|
||||
//!
|
||||
//! This table is the only place in FieldCraft where a conversion factor is written down.
|
||||
|
||||
use super::{Dimension, UnitError};
|
||||
|
||||
/// Exact definitions of the non-metric units (NIST SP 811).
|
||||
const INCH: f64 = 0.0254;
|
||||
const FOOT: f64 = 0.3048;
|
||||
const POUND_MASS: f64 = 0.453_592_37;
|
||||
const STANDARD_GRAVITY: f64 = 9.806_65;
|
||||
const POUND_FORCE: f64 = POUND_MASS * STANDARD_GRAVITY;
|
||||
/// Celsius zero in kelvin.
|
||||
const CELSIUS_ZERO: f64 = 273.15;
|
||||
/// One degree Fahrenheit (or Rankine) in kelvin.
|
||||
const FAHRENHEIT_STEP: f64 = 5.0 / 9.0;
|
||||
/// Fahrenheit zero in kelvin: 459.67 °R.
|
||||
const FAHRENHEIT_ZERO: f64 = 459.67 * FAHRENHEIT_STEP;
|
||||
|
||||
const L: Dimension = Dimension::LENGTH;
|
||||
const M: Dimension = Dimension::MASS;
|
||||
const T: Dimension = Dimension::TIME;
|
||||
const TH: Dimension = Dimension::TEMPERATURE;
|
||||
const ONE: Dimension = Dimension::NONE;
|
||||
const FORCE: Dimension = Dimension::new(1, 1, -2, 0);
|
||||
const PRESSURE: Dimension = Dimension::new(-1, 1, -2, 0);
|
||||
const ENERGY: Dimension = Dimension::new(2, 1, -2, 0);
|
||||
const POWER: Dimension = Dimension::new(2, 1, -3, 0);
|
||||
const FREQUENCY: Dimension = Dimension::new(0, 0, -1, 0);
|
||||
|
||||
/// A named unit: its spellings (the first is the canonical symbol), dimension, the size of one
|
||||
/// unit in SI, and the SI value of its zero (non-zero only for °C and °F).
|
||||
struct Atom {
|
||||
names: &'static [&'static str],
|
||||
dimension: Dimension,
|
||||
factor: f64,
|
||||
offset: f64,
|
||||
}
|
||||
|
||||
const fn atom(names: &'static [&'static str], dimension: Dimension, factor: f64) -> Atom {
|
||||
Atom { names, dimension, factor, offset: 0.0 }
|
||||
}
|
||||
|
||||
const ATOMS: &[Atom] = &[
|
||||
// Length.
|
||||
atom(&["m"], L, 1.0),
|
||||
atom(&["km"], L, 1e3),
|
||||
atom(&["cm"], L, 1e-2),
|
||||
atom(&["mm"], L, 1e-3),
|
||||
atom(&["µm", "μm", "um"], L, 1e-6),
|
||||
atom(&["nm"], L, 1e-9),
|
||||
atom(&["in", "inch"], L, INCH),
|
||||
atom(&["ft"], L, FOOT),
|
||||
// Mass.
|
||||
atom(&["kg"], M, 1.0),
|
||||
atom(&["g"], M, 1e-3),
|
||||
atom(&["mg"], M, 1e-6),
|
||||
atom(&["t", "tonne"], M, 1e3),
|
||||
atom(&["lb", "lbm"], M, POUND_MASS),
|
||||
// Time.
|
||||
atom(&["s"], T, 1.0),
|
||||
atom(&["ms"], T, 1e-3),
|
||||
atom(&["µs", "μs", "us"], T, 1e-6),
|
||||
atom(&["min"], T, 60.0),
|
||||
atom(&["h"], T, 3600.0),
|
||||
atom(&["Hz"], FREQUENCY, 1.0),
|
||||
atom(&["kHz"], FREQUENCY, 1e3),
|
||||
// Temperature: as an absolute temperature °C and °F carry their offset; inside a compound unit
|
||||
// (W/(m·°C)) or as a temperature difference they are only a step size.
|
||||
atom(&["K"], TH, 1.0),
|
||||
Atom { names: &["°C", "degC", "℃"], dimension: TH, factor: 1.0, offset: CELSIUS_ZERO },
|
||||
Atom { names: &["°F", "degF", "℉"], dimension: TH, factor: FAHRENHEIT_STEP, offset: FAHRENHEIT_ZERO },
|
||||
// Force.
|
||||
atom(&["N"], FORCE, 1.0),
|
||||
atom(&["mN"], FORCE, 1e-3),
|
||||
atom(&["kN"], FORCE, 1e3),
|
||||
atom(&["MN"], FORCE, 1e6),
|
||||
atom(&["lbf"], FORCE, POUND_FORCE),
|
||||
atom(&["kip"], FORCE, 1e3 * POUND_FORCE),
|
||||
// Pressure and stress.
|
||||
atom(&["Pa"], PRESSURE, 1.0),
|
||||
atom(&["kPa"], PRESSURE, 1e3),
|
||||
atom(&["MPa"], PRESSURE, 1e6),
|
||||
atom(&["GPa"], PRESSURE, 1e9),
|
||||
atom(&["bar"], PRESSURE, 1e5),
|
||||
atom(&["psi"], PRESSURE, POUND_FORCE / (INCH * INCH)),
|
||||
atom(&["ksi"], PRESSURE, 1e3 * POUND_FORCE / (INCH * INCH)),
|
||||
// Energy and power.
|
||||
atom(&["J"], ENERGY, 1.0),
|
||||
atom(&["mJ"], ENERGY, 1e-3),
|
||||
atom(&["kJ"], ENERGY, 1e3),
|
||||
atom(&["W"], POWER, 1.0),
|
||||
atom(&["mW"], POWER, 1e-3),
|
||||
atom(&["kW"], POWER, 1e3),
|
||||
// Dimensionless: angles and ratios.
|
||||
atom(&["rad"], ONE, 1.0),
|
||||
atom(&["deg", "°"], ONE, std::f64::consts::PI / 180.0),
|
||||
atom(&["%"], ONE, 1e-2),
|
||||
];
|
||||
|
||||
/// Longest accepted unit or quantity text; longer input is refused unparsed.
|
||||
pub(super) const MAX_TEXT: usize = 64;
|
||||
/// Largest accepted exponent in a unit expression (`m^6` is the largest anything needs).
|
||||
const MAX_POWER: i8 = 6;
|
||||
/// Deepest accepted parenthesis nesting.
|
||||
const MAX_DEPTH: usize = 4;
|
||||
|
||||
/// A unit, possibly compound, reduced to SI: `value_si = value * factor + offset`.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Unit {
|
||||
symbol: String,
|
||||
dimension: Dimension,
|
||||
factor: f64,
|
||||
/// The SI value of the unit's zero: non-zero only for a lone °C or °F.
|
||||
offset: f64,
|
||||
/// Whether the unit names an angle (`rad`, `deg`, `°`): only angles accept it.
|
||||
angle: bool,
|
||||
}
|
||||
|
||||
impl Unit {
|
||||
/// Parses a unit expression: named units joined by `*`, `·`, a space or `/`, with integer
|
||||
/// powers (`^2`, `²`, `^-1`, `⁻¹`, or trailing digits as in `mm2`) and parentheses; a leading
|
||||
/// `1` is a numerator (`1/K`). `1` alone is the unit of a dimensionless quantity. `.` is not a
|
||||
/// multiplication sign (it reads as a decimal point).
|
||||
pub fn parse(text: &str) -> Result<Unit, UnitError> {
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return Err(UnitError::Empty);
|
||||
}
|
||||
if text.len() > MAX_TEXT {
|
||||
return Err(UnitError::TooLong);
|
||||
}
|
||||
if text == "1" {
|
||||
return Ok(Unit { symbol: text.to_string(), dimension: Dimension::NONE, factor: 1.0, offset: 0.0, angle: false });
|
||||
}
|
||||
let mut p = Parser { chars: text.chars().collect(), pos: 0, text, angle: false };
|
||||
let (dimension, factor, atoms, offset) = p.expression(0)?;
|
||||
if p.pos != p.chars.len() {
|
||||
return Err(p.syntax());
|
||||
}
|
||||
// An offset only means something for a lone temperature unit.
|
||||
let offset = if atoms == 1 { offset } else { 0.0 };
|
||||
if !(factor.is_finite() && factor > 0.0) {
|
||||
return Err(UnitError::OutOfRange(text.to_string()));
|
||||
}
|
||||
Ok(Unit { symbol: text.to_string(), dimension, factor, offset, angle: p.angle })
|
||||
}
|
||||
|
||||
pub fn symbol(&self) -> &str {
|
||||
&self.symbol
|
||||
}
|
||||
|
||||
pub fn dimension(&self) -> Dimension {
|
||||
self.dimension
|
||||
}
|
||||
|
||||
/// The size of one unit in SI (for °C and °F, the size of one degree).
|
||||
pub fn factor(&self) -> f64 {
|
||||
self.factor
|
||||
}
|
||||
|
||||
/// Whether the unit names an angle (`rad`, `deg`, `°`).
|
||||
pub fn is_angle(&self) -> bool {
|
||||
self.angle
|
||||
}
|
||||
|
||||
/// Converts a value in this unit to SI. `absolute` applies the zero offset (absolute
|
||||
/// temperatures); temperature differences and every other quantity pass `false`.
|
||||
pub(super) fn to_si(&self, value: f64, absolute: bool) -> f64 {
|
||||
value * self.factor + if absolute { self.offset } else { 0.0 }
|
||||
}
|
||||
|
||||
/// Converts an SI value to this unit; the inverse of [`Unit::to_si`].
|
||||
pub(super) fn express(&self, si: f64, absolute: bool) -> f64 {
|
||||
(si - if absolute { self.offset } else { 0.0 }) / self.factor
|
||||
}
|
||||
}
|
||||
|
||||
struct Parser<'a> {
|
||||
chars: Vec<char>,
|
||||
pos: usize,
|
||||
text: &'a str,
|
||||
angle: bool,
|
||||
}
|
||||
|
||||
/// Dimension, factor, number of named units, offset of the last named unit.
|
||||
type Parsed = (Dimension, f64, usize, f64);
|
||||
|
||||
impl Parser<'_> {
|
||||
fn syntax(&self) -> UnitError {
|
||||
UnitError::Syntax(self.text.to_string())
|
||||
}
|
||||
|
||||
fn peek(&self) -> Option<char> {
|
||||
self.chars.get(self.pos).copied()
|
||||
}
|
||||
|
||||
fn skip_spaces(&mut self) -> bool {
|
||||
let start = self.pos;
|
||||
while self.peek().is_some_and(char::is_whitespace) {
|
||||
self.pos += 1;
|
||||
}
|
||||
self.pos > start
|
||||
}
|
||||
|
||||
fn expression(&mut self, depth: usize) -> Result<Parsed, UnitError> {
|
||||
self.skip_spaces();
|
||||
// A leading `1` is a numerator: `1/K`, `1/s`.
|
||||
let leading_one = self.peek() == Some('1') && !self.chars.get(self.pos + 1).is_some_and(char::is_ascii_digit);
|
||||
let (mut dim, mut factor, mut atoms, mut offset) = if leading_one {
|
||||
self.pos += 1;
|
||||
self.skip_spaces();
|
||||
if self.peek() != Some('/') {
|
||||
return Err(self.syntax());
|
||||
}
|
||||
(Dimension::NONE, 1.0, 0, 0.0)
|
||||
} else {
|
||||
self.term(depth)?
|
||||
};
|
||||
loop {
|
||||
let spaced = self.skip_spaces();
|
||||
let divide = match self.peek() {
|
||||
Some('*' | '·') => false,
|
||||
Some('/') => true,
|
||||
// A space between two units multiplies: `N mm`, `W/(m K)`.
|
||||
Some(c) if spaced && (c == '(' || c.is_alphabetic() || matches!(c, '°' | '℃' | '℉' | '%' | 'µ' | 'μ')) => {
|
||||
self.pos -= 1;
|
||||
false
|
||||
}
|
||||
_ => break,
|
||||
};
|
||||
self.pos += 1;
|
||||
self.skip_spaces();
|
||||
let (d, f, a, o) = self.term(depth)?;
|
||||
let overflow = || UnitError::OutOfRange(self.text.to_string());
|
||||
if divide {
|
||||
dim = dim.checked_div(d).ok_or_else(overflow)?;
|
||||
factor /= f;
|
||||
} else {
|
||||
dim = dim.checked_mul(d).ok_or_else(overflow)?;
|
||||
factor *= f;
|
||||
}
|
||||
atoms += a;
|
||||
offset = o;
|
||||
}
|
||||
Ok((dim, factor, atoms, offset))
|
||||
}
|
||||
|
||||
fn term(&mut self, depth: usize) -> Result<Parsed, UnitError> {
|
||||
let (dim, factor, atoms, offset) = if self.peek() == Some('(') {
|
||||
if depth >= MAX_DEPTH {
|
||||
return Err(self.syntax());
|
||||
}
|
||||
self.pos += 1;
|
||||
let inner = self.expression(depth + 1)?;
|
||||
self.skip_spaces();
|
||||
if self.peek() != Some(')') {
|
||||
return Err(self.syntax());
|
||||
}
|
||||
self.pos += 1;
|
||||
inner
|
||||
} else {
|
||||
self.atom()?
|
||||
};
|
||||
let power = self.power()?;
|
||||
if power == 1 {
|
||||
return Ok((dim, factor, atoms, offset));
|
||||
}
|
||||
let dim = dim.checked_pow(power).ok_or_else(|| UnitError::OutOfRange(self.text.to_string()))?;
|
||||
// A powered unit is never a lone absolute temperature.
|
||||
Ok((dim, factor.powi(i32::from(power)), atoms + 1, 0.0))
|
||||
}
|
||||
|
||||
fn atom(&mut self) -> Result<Parsed, UnitError> {
|
||||
let start = self.pos;
|
||||
while let Some(c) = self.peek() {
|
||||
if c.is_alphabetic() || matches!(c, '°' | '℃' | '℉' | '%' | 'µ' | 'μ') {
|
||||
self.pos += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if self.pos == start {
|
||||
return Err(self.syntax());
|
||||
}
|
||||
let name: String = self.chars.get(start..self.pos).unwrap_or_default().iter().collect();
|
||||
let atom = ATOMS.iter().find(|a| a.names.contains(&name.as_str())).ok_or_else(|| UnitError::UnknownUnit(name.clone()))?;
|
||||
self.angle |= matches!(atom.names.first(), Some(&("rad" | "deg")));
|
||||
Ok((atom.dimension, atom.factor, 1, atom.offset))
|
||||
}
|
||||
|
||||
/// An optional integer exponent; 1 when absent.
|
||||
fn power(&mut self) -> Result<i8, UnitError> {
|
||||
let mut digits = String::new();
|
||||
if self.peek() == Some('^') {
|
||||
self.pos += 1;
|
||||
if self.peek() == Some('-') {
|
||||
digits.push('-');
|
||||
self.pos += 1;
|
||||
}
|
||||
while let Some(c) = self.peek().filter(char::is_ascii_digit) {
|
||||
digits.push(c);
|
||||
self.pos += 1;
|
||||
}
|
||||
} else {
|
||||
while let Some(c) = self.peek() {
|
||||
let mapped = match c {
|
||||
'⁻' => '-',
|
||||
'¹' => '1',
|
||||
'²' => '2',
|
||||
'³' => '3',
|
||||
'⁴' => '4',
|
||||
'⁵' => '5',
|
||||
'⁶' => '6',
|
||||
d if d.is_ascii_digit() => d,
|
||||
_ => break,
|
||||
};
|
||||
digits.push(mapped);
|
||||
self.pos += 1;
|
||||
}
|
||||
if digits.is_empty() {
|
||||
return Ok(1);
|
||||
}
|
||||
}
|
||||
match digits.parse::<i8>() {
|
||||
Ok(p) if p != 0 && p.unsigned_abs() <= MAX_POWER.unsigned_abs() => Ok(p),
|
||||
_ => Err(self.syntax()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn close(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() <= 1e-12 * a.abs().max(b.abs())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compound_units_reduce_to_si() {
|
||||
let cases = [
|
||||
("t/mm³", Dimension::new(-3, 1, 0, 0), 1e12),
|
||||
("t/mm^3", Dimension::new(-3, 1, 0, 0), 1e12),
|
||||
("kg/m3", Dimension::new(-3, 1, 0, 0), 1.0),
|
||||
("N/mm²", PRESSURE, 1e6),
|
||||
("W/(m·K)", Dimension::new(1, 1, -3, -1), 1.0),
|
||||
("mW/(mm*K)", Dimension::new(1, 1, -3, -1), 1.0),
|
||||
("W/m/K", Dimension::new(1, 1, -3, -1), 1.0),
|
||||
("K^-1", Dimension::new(0, 0, 0, -1), 1.0),
|
||||
("lbf·s²/in", M, POUND_FORCE / INCH),
|
||||
("mm/s", Dimension::new(1, 0, -1, 0), 1e-3),
|
||||
("MPa·s", Dimension::new(-1, 1, -1, 0), 1e6),
|
||||
];
|
||||
for (text, dim, factor) in cases {
|
||||
let u = Unit::parse(text).unwrap_or_else(|e| panic!("{text}: {e}"));
|
||||
assert_eq!(u.dimension(), dim, "{text}");
|
||||
assert!(close(u.factor(), factor), "{text}: {} vs {factor}", u.factor());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numerators_and_spaces() {
|
||||
let k = Unit::parse("1/K").unwrap();
|
||||
assert_eq!(k.dimension(), Dimension::new(0, 0, 0, -1));
|
||||
assert_eq!(Unit::parse("1/s").unwrap().dimension(), FREQUENCY);
|
||||
assert_eq!(Unit::parse("1 / (m·K)").unwrap().dimension(), Dimension::new(-1, 0, 0, -1));
|
||||
for (spaced, plain) in
|
||||
[("N · mm", "N·mm"), ("W/(m K)", "W/(m·K)"), ("kg m^-3", "kg/m^3"), ("W / ( m * K )", "W/(m·K)"), ("lbf s² / in", "lbf·s²/in")]
|
||||
{
|
||||
let (a, b) = (Unit::parse(spaced).unwrap(), Unit::parse(plain).unwrap());
|
||||
assert_eq!(a.dimension(), b.dimension(), "{spaced}");
|
||||
assert!(close(a.factor(), b.factor()), "{spaced}");
|
||||
}
|
||||
assert!(Unit::parse("deg").unwrap().is_angle());
|
||||
assert!(Unit::parse("°").unwrap().is_angle());
|
||||
assert!(!Unit::parse("%").unwrap().is_angle());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offsets_only_for_lone_temperatures() {
|
||||
assert!(close(Unit::parse("°C").unwrap().to_si(20.0, true), 293.15));
|
||||
assert!(close(Unit::parse("°F").unwrap().to_si(32.0, true), 273.15));
|
||||
assert!(close(Unit::parse("°F").unwrap().to_si(212.0, true), 373.15));
|
||||
// In a compound unit the degree is a step.
|
||||
let k = Unit::parse("W/(m·°C)").unwrap();
|
||||
assert!(close(k.to_si(1.0, true), 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_units_are_refused() {
|
||||
for text in [
|
||||
"",
|
||||
" ",
|
||||
"m^",
|
||||
"m^0",
|
||||
"m^99",
|
||||
"m^-128",
|
||||
"((((((m))))))",
|
||||
"(m",
|
||||
"m)",
|
||||
"m//s",
|
||||
"mm2x",
|
||||
"foo",
|
||||
"m^7",
|
||||
"BTU",
|
||||
"m.s",
|
||||
"1 K",
|
||||
"11/K",
|
||||
"2/K",
|
||||
"1/",
|
||||
] {
|
||||
assert!(Unit::parse(text).is_err(), "{text:?}");
|
||||
}
|
||||
assert!(matches!(Unit::parse(&"m".repeat(100)), Err(UnitError::TooLong)));
|
||||
// Exponent overflow across many factors.
|
||||
assert!(Unit::parse("(m^6)^6·(m^6)^6·(m^6)^6·(m^6)^6").is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
//! The unit table against hand-computed values.
|
||||
|
||||
use fieldcraft_model::units::{DisplaySystem, Preset, Quantity, Unit, UnitError, ValueInput};
|
||||
|
||||
fn close(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() <= 1e-12 * a.abs().max(b.abs()).max(1e-300)
|
||||
}
|
||||
|
||||
fn si(q: Quantity, text: &str) -> f64 {
|
||||
q.parse(text, &DisplaySystem::new(Preset::Si)).unwrap_or_else(|e| panic!("{text}: {e}"))
|
||||
}
|
||||
|
||||
fn shown(q: Quantity, si_value: f64, preset: Preset) -> f64 {
|
||||
q.express(si_value, &DisplaySystem::new(preset).unit(q).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hand_values() {
|
||||
use Quantity as Q;
|
||||
// E = 210 GPa = 210 000 MPa = 2.1e11 Pa.
|
||||
let e = si(Q::Pressure, "210 GPa");
|
||||
assert!(close(e, 2.1e11));
|
||||
assert!(close(si(Q::Pressure, "210000 MPa"), e));
|
||||
assert!(close(shown(Q::Pressure, e, Preset::MmTonneS), 210_000.0));
|
||||
// 1 N/mm² = 1 MPa.
|
||||
assert!(close(si(Q::Pressure, "1 N/mm²"), si(Q::Pressure, "1 MPa")));
|
||||
// Steel density 7850 kg/m³ = 7.85e-9 t/mm³.
|
||||
let rho = si(Q::Density, "7850 kg/m³");
|
||||
assert!(close(shown(Q::Density, rho, Preset::MmTonneS), 7.85e-9));
|
||||
assert!(close(si(Q::Density, "7.85e-9 t/mm^3"), 7850.0));
|
||||
// 20 °C = 293.15 K, but a difference of 20 °C is 20 K.
|
||||
assert!(close(si(Q::Temperature, "20 °C"), 293.15));
|
||||
assert!(close(si(Q::TemperatureDifference, "20 °C"), 20.0));
|
||||
assert!(close(si(Q::Temperature, "68 °F"), 293.15));
|
||||
assert!(close(si(Q::TemperatureDifference, "9 °F"), 5.0));
|
||||
assert!(close(shown(Q::Temperature, 293.15, Preset::MmTonneS), 20.0));
|
||||
assert!(close(shown(Q::Temperature, 293.15, Preset::InLbfS), 68.0));
|
||||
// Expansion 12e-6 /°C = 12e-6 /K; 6.667e-6 /°F.
|
||||
assert!(close(si(Q::ThermalExpansion, "12e-6 °C⁻¹"), 12e-6));
|
||||
assert!(close(shown(Q::ThermalExpansion, 12e-6, Preset::InLbfS), 12e-6 * 5.0 / 9.0));
|
||||
// 25 mm/s = 0.025 m/s; g = 9.81 m/s² = 9810 mm/s² = 386.22 in/s².
|
||||
assert!(close(si(Q::Velocity, "25 mm/s"), 0.025));
|
||||
assert!(close(shown(Q::Acceleration, 9.81, Preset::MmTonneS), 9810.0));
|
||||
assert!(close(shown(Q::Acceleration, 9.81, Preset::InLbfS), 9.81 / 0.0254));
|
||||
// Steel in in-lbf-s: E = 2.1e11 Pa = 30.458 Msi; ρ = 7850 kg/m³ = 7.345e-4 lbf·s²/in⁴.
|
||||
let psi = 4.448_221_615_260_5 / (0.0254 * 0.0254);
|
||||
assert!(close(shown(Q::Pressure, e, Preset::InLbfS), 2.1e11 / psi));
|
||||
assert!(close(shown(Q::Density, rho, Preset::InLbfS), 7850.0 * 0.0254 / 4.448_221_615_260_5 * 0.0254 * 0.0254 * 0.0254));
|
||||
// Steel conductivity 50 W/(m·K) is 50 mW/(mm·K); specific heat 460 J/(kg·K) is 4.6e8 mJ/(t·K).
|
||||
assert!(close(shown(Q::ThermalConductivity, 50.0, Preset::MmTonneS), 50.0));
|
||||
assert!(close(shown(Q::SpecificHeat, 460.0, Preset::MmTonneS), 4.6e8));
|
||||
// 10 W/(m²·K) = 0.01 mW/(mm²·K); 1 kW/m² = 1 mW/mm².
|
||||
assert!(close(shown(Q::HeatTransferCoefficient, 10.0, Preset::MmTonneS), 0.01));
|
||||
assert!(close(shown(Q::HeatFlux, 1000.0, Preset::MmTonneS), 1.0));
|
||||
// Water viscosity 1e-3 Pa·s = 1e-9 MPa·s; 1 N·mm = 1e-3 N·m = 1 mJ.
|
||||
assert!(close(shown(Q::DynamicViscosity, 1e-3, Preset::MmTonneS), 1e-9));
|
||||
assert!(close(si(Q::Moment, "1 N·mm"), si(Q::Energy, "1 mJ")));
|
||||
// 90 deg = π/2 rad; 5 % = 0.05.
|
||||
assert!(close(si(Q::Angle, "90 deg"), std::f64::consts::FRAC_PI_2));
|
||||
assert!(close(si(Q::Dimensionless, "5 %"), 0.05));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_preset_unit_has_its_quantitys_dimension() {
|
||||
for preset in Preset::ALL {
|
||||
let display = DisplaySystem::new(preset);
|
||||
for &q in Quantity::ALL {
|
||||
let unit = display.unit(q).unwrap_or_else(|e| panic!("{preset:?} {q:?}: {e}"));
|
||||
assert_eq!(unit.dimension(), q.dimension(), "{preset:?} {q:?} `{}`", unit.symbol());
|
||||
}
|
||||
assert_eq!(Preset::from_label(preset.label()), Some(preset));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mm_tonne_second_is_consistent() {
|
||||
// A consistent system: force = mass · acceleration and stress = force / area hold with no
|
||||
// factor, so the deck writers can write its numbers directly.
|
||||
let d = DisplaySystem::new(Preset::MmTonneS);
|
||||
let f = |q| d.unit(q).unwrap().factor();
|
||||
use Quantity as Q;
|
||||
assert!(close(f(Q::Force), f(Q::Mass) * f(Q::Acceleration)));
|
||||
assert!(close(f(Q::Pressure), f(Q::Force) / f(Q::Area)));
|
||||
assert!(close(f(Q::Energy), f(Q::Force) * f(Q::Length)));
|
||||
assert!(close(f(Q::Density), f(Q::Mass) / f(Q::Volume)));
|
||||
let i = DisplaySystem::new(Preset::InLbfS);
|
||||
let g = |q| i.unit(q).unwrap().factor();
|
||||
assert!(close(g(Q::Force), g(Q::Mass) * g(Q::Acceleration)));
|
||||
assert!(close(g(Q::Pressure), g(Q::Force) / g(Q::Area)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_through_every_preset() {
|
||||
let values = [(Quantity::Pressure, 2.1e11), (Quantity::Density, 7850.0), (Quantity::Temperature, 293.15), (Quantity::Length, 0.0254)];
|
||||
for preset in Preset::ALL {
|
||||
let d = DisplaySystem::new(preset);
|
||||
for (q, v) in values {
|
||||
let text = q.format(v, &d).unwrap();
|
||||
let back = q.parse(&text, &d).unwrap();
|
||||
assert!((back - v).abs() <= 1e-5 * v.abs(), "{preset:?} {q:?}: {text} -> {back}");
|
||||
}
|
||||
}
|
||||
let mm = DisplaySystem::default();
|
||||
assert_eq!(Quantity::Pressure.format(2.1e11, &mm).unwrap(), "210000 MPa");
|
||||
assert_eq!(Quantity::Density.format(7850.0, &mm).unwrap(), "7.85e-9 t/mm³");
|
||||
assert_eq!(Quantity::Temperature.format(293.15, &mm).unwrap(), "20 °C");
|
||||
assert_eq!(Quantity::Dimensionless.format(0.3, &mm).unwrap(), "0.3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inputs_from_commands() {
|
||||
let mm = DisplaySystem::default();
|
||||
let parse = |json: &str| serde_json::from_str::<ValueInput>(json).unwrap().to_si(Quantity::Pressure, &mm);
|
||||
assert!(close(parse("210000").unwrap(), 2.1e11)); // bare number: display system (MPa)
|
||||
assert!(close(parse(r#""210 GPa""#).unwrap(), 2.1e11));
|
||||
assert!(close(parse(r#"{"value": 210, "unit": "GPa"}"#).unwrap(), 2.1e11));
|
||||
assert!(close(Quantity::Pressure.parse("210000", &mm).unwrap(), 2.1e11));
|
||||
assert!(serde_json::from_str::<ValueInput>(r#"{"value": 1, "unit": "Pa", "extra": 0}"#).is_err());
|
||||
let over = DisplaySystem::default().with_override(Quantity::Energy, "J").unwrap();
|
||||
assert_eq!(Quantity::Energy.format(2.0, &over).unwrap(), "2 J");
|
||||
assert!(DisplaySystem::default().with_override(Quantity::Energy, "MPa").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_values_are_refused() {
|
||||
let mm = DisplaySystem::default();
|
||||
use Quantity as Q;
|
||||
let wrong = Q::Pressure.parse("210 mm", &mm);
|
||||
assert!(matches!(wrong, Err(UnitError::WrongDimension { .. })), "{wrong:?}");
|
||||
assert_eq!(wrong.unwrap_err().to_string(), "`mm` is L, but a pressure is L^-1 M T^-2");
|
||||
for text in ["", "GPa", "nan GPa", "inf Pa", "1e400 Pa", "1e300 GPa", "210 GPa extra", "210 parsecs", "--1 Pa"] {
|
||||
assert!(Q::Pressure.parse(text, &mm).is_err(), "{text:?}");
|
||||
}
|
||||
assert!(Q::Density.parse("1e300 t/mm³", &mm).is_err());
|
||||
assert!(matches!(Q::Temperature.parse("-300 °C", &mm), Err(UnitError::BelowAbsoluteZero(_))));
|
||||
assert!(Q::TemperatureDifference.parse("-300 °C", &mm).is_ok());
|
||||
assert!(Q::Pressure.to_si(f64::NAN, &Unit::parse("Pa").unwrap()).is_err());
|
||||
assert!(Q::Pressure.parse(&"9".repeat(500), &mm).is_err());
|
||||
let input = ValueInput::WithUnit(fieldcraft_model::units::WithUnit { value: f64::INFINITY, unit: "Pa".into() });
|
||||
assert!(input.to_si(Q::Pressure, &mm).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn angles_and_ratios_are_kept_apart() {
|
||||
let mm = DisplaySystem::default();
|
||||
use Quantity as Q;
|
||||
assert!(matches!(Q::Angle.parse("5 %", &mm), Err(UnitError::WrongKind { .. })));
|
||||
assert!(matches!(Q::Dimensionless.parse("1 deg", &mm), Err(UnitError::WrongKind { .. })));
|
||||
assert!(matches!(Q::Angle.parse("90 mm", &mm), Err(UnitError::WrongDimension { .. })));
|
||||
assert!(Q::Pressure.parse("1 MPa·deg", &mm).is_err());
|
||||
assert!(close(Q::Angle.parse("90 °", &mm).unwrap(), std::f64::consts::FRAC_PI_2));
|
||||
assert!(close(Q::Angle.parse("90", &mm).unwrap(), std::f64::consts::FRAC_PI_2)); // display: deg
|
||||
assert!(close(Q::Dimensionless.parse("0.3", &mm).unwrap(), 0.3));
|
||||
assert!(close(Q::ThermalExpansion.parse("12e-6 1/K", &mm).unwrap(), 12e-6));
|
||||
assert!(close(Q::Frequency.parse("0.01 1/s", &mm).unwrap(), 0.01));
|
||||
assert!(close(Q::ThermalConductivity.parse("50 W/(m K)", &mm).unwrap(), 50.0));
|
||||
}
|
||||
@@ -1115,7 +1115,7 @@ impl ModelApp {
|
||||
/// frame; `None` when no results are shown.
|
||||
fn plot_key(&self) -> Option<PlotKey> {
|
||||
let (view, result, current) = results::shown(&self.session)?;
|
||||
let frame = results::frame(view, result, self.now);
|
||||
let frame = results::frame(view, self.now);
|
||||
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 })
|
||||
}
|
||||
@@ -1130,8 +1130,7 @@ impl ModelApp {
|
||||
let still = key.as_ref().map(|k| PlotKey { frame: None, ..k.clone() });
|
||||
if self.prepared.0 != still {
|
||||
let pick = key.as_ref().map(|k| k.pick).unwrap_or_default();
|
||||
let bodies: Vec<&str> = self.pick.names.iter().map(|n| n.name.as_str()).collect();
|
||||
self.prepared = (still, results::prepare(&self.session, &self.display.1, pick, &bodies).unwrap_or_default());
|
||||
self.prepared = (still, results::prepare(&self.session, &self.display.1, pick).unwrap_or_default());
|
||||
}
|
||||
let frame = key.as_ref().and_then(|k| k.frame);
|
||||
let p = key.as_ref().and_then(|_| results::plot(&self.session, &self.display.1, frame, &self.prepared.1));
|
||||
|
||||
+18
-115
@@ -114,18 +114,8 @@ impl Plot {
|
||||
|
||||
/// Where a contour extreme is: its node's position, or a fluid step's cell centre.
|
||||
pub fn extreme_position(&self, label: u32) -> Option<Vec3> {
|
||||
if self.view.contour == Some(Field::WallHeatFlux) {
|
||||
return self.flow.as_ref()?.wall_positions.get(label as usize).copied().flatten();
|
||||
}
|
||||
match self.result.kind() {
|
||||
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))),
|
||||
}
|
||||
}
|
||||
Kind::Fluid(f) => Some(Vec3::from(fieldcraft_engine::flow::cell_centre(f, usize::try_from(label).ok()?))),
|
||||
_ => self.node_position(label),
|
||||
}
|
||||
}
|
||||
@@ -143,12 +133,9 @@ pub fn shown(session: &Session) -> Option<(&ResultsView, &Arc<StepResult>, bool)
|
||||
Some((view, result, current))
|
||||
}
|
||||
|
||||
/// The animation frame shown at time `now` (seconds), when animating. A deformed step swings its
|
||||
/// shape; a fluid step plays its frames though it does not deform; a step with nothing to animate
|
||||
/// (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 animation frame shown at time `now` (seconds), when animating.
|
||||
pub fn frame(view: &ResultsView, now: f64) -> Option<u64> {
|
||||
(view.animate && view.deformed && 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
|
||||
@@ -171,7 +158,6 @@ pub fn pick(view: &ResultsView, result: &StepResult, frame: Option<u64>) -> Pick
|
||||
let mut p = view.pick();
|
||||
let frames = match result.kind() {
|
||||
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()),
|
||||
_ => 0,
|
||||
};
|
||||
@@ -189,12 +175,10 @@ pub fn scale(view: &ResultsView, result: &StepResult, pick: Pick, base: &Display
|
||||
|
||||
/// The part of the session's results plot that depends on what is drawn (`pick`), not on the
|
||||
/// animation's deformation multiplier: the deformation scale and the contour values.
|
||||
pub fn prepare(session: &Session, base: &DisplayMesh, pick: Pick, bodies: &[&str]) -> Option<Prepared> {
|
||||
pub fn prepare(session: &Session, base: &DisplayMesh, pick: Pick) -> Option<Prepared> {
|
||||
let (view, result, _) = shown(session)?;
|
||||
if matches!(result.kind(), Kind::Fluid(_)) {
|
||||
let mut view = view.clone();
|
||||
view.frame = pick.frame;
|
||||
let flow = fieldcraft_engine::flow::plot_with_surfaces(&view, result, &session.traced, session, base, bodies).ok();
|
||||
let flow = fieldcraft_engine::flow::plot(view, result, &session.traced).ok();
|
||||
let cell = |(k, v): (usize, f64)| (u32::try_from(k).unwrap_or(u32::MAX), v);
|
||||
let contour = flow
|
||||
.as_ref()
|
||||
@@ -352,8 +336,7 @@ pub(crate) mod tests {
|
||||
|
||||
fn plot_at(s: &Session, base: &DisplayMesh, frame: Option<u64>) -> Option<Plot> {
|
||||
let (view, result, _) = shown(s)?;
|
||||
let bodies: Vec<&str> = s.design.iter().flat_map(|d| &d.bodies).map(|b| b.name.as_str()).collect();
|
||||
plot(s, base, frame, &prepare(s, base, pick(view, result, frame), &bodies)?)
|
||||
plot(s, base, frame, &prepare(s, base, pick(view, result, frame))?)
|
||||
}
|
||||
|
||||
/// The sample cantilever, solved (static step `Load`, frequency step `Modes`).
|
||||
@@ -429,39 +412,22 @@ pub(crate) mod tests {
|
||||
}
|
||||
|
||||
/// A 40 × 30 × 10 mm plate in a tank of 5 mm cells, a liquid flowing past it along x, solved.
|
||||
fn plate_in_a_tank(thermal: bool) -> Session {
|
||||
fn plate_in_a_tank() -> Session {
|
||||
use fieldcraft_geometry::sample::{Plate, plate_design};
|
||||
let dir = std::env::temp_dir().join(format!("fieldcraft-ui-flow-{}-{thermal}", std::process::id()));
|
||||
let dir = std::env::temp_dir().join(format!("fieldcraft-ui-flow-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("plate.solidcraft");
|
||||
std::fs::write(&path, plate_design(&Plate::default()).unwrap()).unwrap();
|
||||
let mut s = Session::default();
|
||||
s.execute("geometry.open", &json!({"path": path.to_str().unwrap()})).unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let mut script = json!([
|
||||
s.run_script(&json!([
|
||||
{"command": "model.material.create", "params": {"name": "Water", "density": 1e-9, "fluid": {"type": "newtonian", "viscosity": 1e-7}}},
|
||||
{"command": "model.fluid_domain.create", "params": {"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}}}},
|
||||
{"command": "model.step.create", "params": {"name": "Flow", "procedure": {"type": "fluid", "domain": "Tank", "steps": 60, "frames": 2}}},
|
||||
]);
|
||||
if thermal {
|
||||
script[0]["params"]["thermal"] = json!({"conductivity": 1.0, "specific_heat": 1e8});
|
||||
let faces: Vec<_> = s.design.as_ref().unwrap().bodies[0]
|
||||
.faces()
|
||||
.iter()
|
||||
.map(|f| {
|
||||
json!({
|
||||
"face": {"body": "Body1", "face": f.name}, "condition": {"type": "temperature", "value": 60.0}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
script[1]["params"]["thermal"] =
|
||||
json!({"initial_temperature": 20.0, "surfaces": faces, "faces": {"x_min": {"type": "temperature", "value": 20.0}}});
|
||||
}
|
||||
s.run_script(&script).unwrap();
|
||||
if thermal {
|
||||
s.execute("model.units", &json!({"system": "mm-t-s-N-MPa"})).unwrap();
|
||||
}
|
||||
]))
|
||||
.unwrap();
|
||||
s.execute("solve.fluid", &json!({})).unwrap();
|
||||
s
|
||||
}
|
||||
@@ -470,7 +436,7 @@ pub(crate) mod tests {
|
||||
/// slice and on the part, with its legend, and streamlines (#193).
|
||||
#[test]
|
||||
fn a_fluid_step_keeps_the_part_and_draws_the_flow() {
|
||||
let mut s = plate_in_a_tank(false);
|
||||
let mut s = plate_in_a_tank();
|
||||
let (base, _) = fieldcraft_engine::view::display(&s);
|
||||
assert!(!base.surface_tris.is_empty());
|
||||
let p = plot_at(&s, &base, None).unwrap();
|
||||
@@ -485,71 +451,12 @@ pub(crate) mod tests {
|
||||
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.lines.is_empty(), "streamlines and the part's edges are drawn");
|
||||
// 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).
|
||||
// Pressure on the slice; the first frame (at rest at the inlet speed everywhere).
|
||||
s.execute("results.slice", &json!({"field": "pressure", "frame": 0})).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)));
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn heated_flow_fields_have_units_colours_and_face_markers() {
|
||||
let mut s = plate_in_a_tank(true);
|
||||
let (base, _) = fieldcraft_engine::view::display(&s);
|
||||
s.execute("results.slice", &json!({"field": "temperature"})).unwrap();
|
||||
let p = plot_at(&s, &base, None).unwrap();
|
||||
assert_eq!(p.contour.as_ref().unwrap().field, Field::Temperature);
|
||||
assert!(Field::Temperature.title(&p.result, p.units).contains("°C or K"));
|
||||
assert!(Field::available(&p.result).any(|f| f == Field::Temperature));
|
||||
assert!(p.contour.as_ref().unwrap().max.1 > 20.0);
|
||||
let last = p.frame().unwrap().2 - 1;
|
||||
s.execute("results.frame", &json!({"frame": 0})).unwrap();
|
||||
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.surface", &json!({"field": "wall_heat_flux"})).unwrap();
|
||||
let p = plot_at(&s, &base, None).unwrap();
|
||||
let c = p.contour.as_ref().unwrap();
|
||||
assert_eq!(c.field, Field::WallHeatFlux);
|
||||
assert_eq!(c.field.unit(&p.result, p.units).as_deref(), Some("mW/mm²"));
|
||||
assert!(c.min.1 < 0.0, "hot wall gives heat to fluid");
|
||||
assert!(p.extreme_position(c.min.0).is_some());
|
||||
assert!(p.flow.as_ref().unwrap().slice.is_none());
|
||||
for t in [Tokens::light(), Tokens::dark()] {
|
||||
let scene = scene(&p, Style::ShadedEdges, crate::scene::Show::DEFAULT, &t);
|
||||
assert_eq!(scene.tris.len(), base.surface_tris.len());
|
||||
assert!(scene.tris.windows(2).any(|t| t[0].color != t[1].color));
|
||||
}
|
||||
s.execute("results.frame", &json!({"frame": 0})).unwrap();
|
||||
assert!(plot_at(&s, &base, None).unwrap().contour.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modes_swing_and_static_steps_grow_when_animated() {
|
||||
let mut s = solved_cantilever();
|
||||
@@ -557,14 +464,10 @@ pub(crate) mod tests {
|
||||
s.execute("results.animate", &json!({})).unwrap();
|
||||
let base = DisplayMesh::from_model(&s.model);
|
||||
let view = s.results_view.clone().unwrap();
|
||||
let result = s.latest_result(&view.step).unwrap().0;
|
||||
assert_eq!(frame(&view, result, 0.0), Some(0));
|
||||
assert_eq!(frame(&view, result, PERIOD * 0.25), Some(FRAMES / 4));
|
||||
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_eq!(frame(&view, 0.0), Some(0));
|
||||
assert_eq!(frame(&view, PERIOD * 0.25), Some(FRAMES / 4));
|
||||
assert_eq!(frame(&view, PERIOD * 1.25), Some(FRAMES / 4));
|
||||
let (_, result, _) = shown(&s).unwrap();
|
||||
assert!((swing(result, Some(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.
|
||||
|
||||
@@ -41,8 +41,7 @@ pub fn show(app: &mut ModelApp, ui: &mut egui::Ui, rect: Rect, t: &Tokens) {
|
||||
probe(app, &plot, &painter, rect, t);
|
||||
let mut focus = None;
|
||||
for (i, (line, node)) in extremes.into_iter().enumerate() {
|
||||
let tip = if plot.is_fluid() { "Centre this field extreme".into() } else { format!("Show and probe node {node}") };
|
||||
let r = ui.interact(line, ui.id().with(("fc_legend_extreme", i)), egui::Sense::click()).on_hover_text(tip);
|
||||
let r = ui.interact(line, ui.id().with(("fc_legend_extreme", i)), egui::Sense::click()).on_hover_text(format!("Show and probe node {node}"));
|
||||
if r.hovered() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||
}
|
||||
@@ -87,7 +86,6 @@ fn title(plot: &Plot) -> String {
|
||||
match (&plot.contour, plot.mode()) {
|
||||
(Some(l), _) => l.field.title(&plot.result, plot.units),
|
||||
(None, Some(_)) => "Mode shape (normalised)".into(),
|
||||
(None, None) if plot.is_fluid() => plot.view.contour.map_or_else(|| "Flow".into(), |f| f.title(&plot.result, plot.units)),
|
||||
(None, None) => "Deformed shape".into(),
|
||||
}
|
||||
}
|
||||
@@ -364,10 +362,6 @@ 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() {
|
||||
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));
|
||||
} else if let Some((k, factor, n)) = plot.frame().filter(|_| matches!(plot.result.kind(), Kind::Static(_))) {
|
||||
@@ -480,23 +474,9 @@ fn deformation_rows(plot: &Plot, ui: &mut egui::Ui, run: &mut Vec<(&'static str,
|
||||
fn flow_rows(plot: &Plot, ui: &mut egui::Ui, run: &mut Vec<(&'static str, Value)>) {
|
||||
use fieldcraft_engine::flow::{Axis, slice_position};
|
||||
let (Kind::Fluid(f), Some(flow)) = (plot.result.kind(), plot.flow.as_ref()) else { return };
|
||||
if plot.view.contour == Some(Field::WallHeatFlux) {
|
||||
ui.label("Wall heat");
|
||||
ui.label("Positive into wall; face average");
|
||||
ui.end_row();
|
||||
if plot.contour.is_none() {
|
||||
ui.label("History");
|
||||
ui.label("No wall heat recorded at this frame");
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
let view = &flow.view;
|
||||
ui.label("Slice");
|
||||
ui.horizontal(|ui| {
|
||||
if plot.view.contour == Some(Field::WallHeatFlux) {
|
||||
ui.label("Hidden for wall heat");
|
||||
return;
|
||||
}
|
||||
let shown = view.slice.as_ref().map_or("None", |s| s.axis.id());
|
||||
egui::ComboBox::from_id_salt("fc_results_slice").selected_text(format!("Normal {}", shown.to_uppercase())).width(90.0).show_ui(ui, |ui| {
|
||||
if ui.selectable_label(view.slice.is_none(), "None").clicked() {
|
||||
@@ -524,14 +504,14 @@ fn flow_rows(plot: &Plot, ui: &mut egui::Ui, run: &mut Vec<(&'static str, Value)
|
||||
ui.end_row();
|
||||
ui.label("Obstacles");
|
||||
let mut surface = view.surface;
|
||||
if ui.checkbox(&mut surface, "Coloured").on_hover_text("Colour the part by the selected fluid field or wall heat").changed() {
|
||||
if ui.checkbox(&mut surface, "Coloured").on_hover_text("Colour the part by the field in the fluid beside it").changed() {
|
||||
run.push(("results.surface", json!({"show": surface})));
|
||||
}
|
||||
ui.end_row();
|
||||
ui.label("Streamlines");
|
||||
ui.horizontal(|ui| {
|
||||
let mut on = view.streamlines.is_some();
|
||||
if ui.checkbox(&mut on, "").on_hover_text("Streamlines from the inlet, coloured by speed or temperature when selected").changed() {
|
||||
if ui.checkbox(&mut on, "").on_hover_text("Streamlines from the inlet, coloured by speed when the legend shows velocity").changed() {
|
||||
run.push(("results.streamlines", json!({"show": on})));
|
||||
}
|
||||
if let Some(sl) = &view.streamlines {
|
||||
|
||||
@@ -20,7 +20,6 @@ const THUMBNAILS: &[(&str, &[u8])] = &[
|
||||
("heated-bracket", include_bytes!("../thumbnails/heated-bracket.png")),
|
||||
("bracket-hex", include_bytes!("../thumbnails/bracket-hex.png")),
|
||||
("flow-past", include_bytes!("../thumbnails/flow-past.png")),
|
||||
("heated-flow", include_bytes!("../thumbnails/heated-flow.png")),
|
||||
("cantilever", include_bytes!("../thumbnails/cantilever.png")),
|
||||
("impact", include_bytes!("../thumbnails/impact.png")),
|
||||
("parts", include_bytes!("../thumbnails/parts.png")),
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 5.7 KiB |
@@ -139,28 +139,3 @@ factorization's supernodes, the tet mesher's refinement and improvement passes,
|
||||
attempts) and stops with `cancelled`, leaving the model unchanged. One job runs at a
|
||||
time; another long command meanwhile is refused, naming the job. The MCP server's `solve` and
|
||||
`mesh` tools wait for the reply.
|
||||
|
||||
## Heated flow results
|
||||
|
||||
A solved fluid step with temperature in every saved frame offers `temperature` in the
|
||||
Results field list and through `results.contour`, `results.slice`, `results.surface` and
|
||||
`results.streamlines` (`{"field":"temperature"}`). Slices and obstacle surfaces sample
|
||||
fluid-cell temperature; streamline colours sample temperature along the velocity traces.
|
||||
The legend and existing `solve.probe` use the model's temperature scale, without conversion.
|
||||
An isothermal step hides the field and rejects an explicit request with an explanation.
|
||||
The frame slider and `results.frame` select the field's time, including the initial frame.
|
||||
|
||||
`results.surface {"field":"wall_heat_flux","frame":10}` shows the signed heat received
|
||||
by each obstacle face divided by that face's complete tessellated geometric area. Positive
|
||||
values heat the wall; a hot wall heating the fluid is negative. This is a face average,
|
||||
not a resolved local distribution. The legend uses the model's consistent power/area unit:
|
||||
for mm–tonne–second it is mW/mm² (not W/mm²); other systems follow their own units.
|
||||
|
||||
Wall heat uses the recorded face-group histories, linearly interpolated at the shown frame
|
||||
only between recorded times. The initial frame precedes the history and remains uncoloured;
|
||||
no heat is invented or extrapolated. Recorded adiabatic faces are zero. Unrecorded/imported
|
||||
faces remain in their own colour. The command reply includes `wall_heat.faces` with body,
|
||||
face, area, power and flux (null when unavailable), plus the sign and sampling convention.
|
||||
This surface-only field suppresses the slice and leaves streamlines plain; selecting a cell
|
||||
field restores the saved slice settings. `results.contour` also selects the wall field and
|
||||
controls its bands/range. The desktop and headless picture use the same mapping and palette.
|
||||
+2
-29
@@ -6,7 +6,7 @@ press **Solve** (`solve.run`). Each opens fully defined — geometry, mesh, mate
|
||||
conditions, loads and steps — in mm, t, s, N and MPa, and opens and solves within half a minute. The geometry is our
|
||||
own: SolidCraft scripts run when the sample opens (a recipe from [`tests/corpus`](../tests/corpus)
|
||||
or one written in `crates/engine/src/sample.rs`). Each sample has a test in
|
||||
`crates/engine/src/sample.rs` or its modules that opens it, checks it clean, solves it and holds its result to
|
||||
`crates/engine/src/sample.rs` that opens it, checks it clean, solves it and holds its result to
|
||||
the check given below.
|
||||
|
||||
## Drop test (`drop-test`)
|
||||
@@ -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
|
||||
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;
|
||||
D3Q19 lattice Boltzmann (TRT) for 16 s, about 1.7 passes through the 240 mm box, ten frames. Reynolds number
|
||||
D3Q19 lattice Boltzmann (TRT) for 16 s, about 6.7 flow-throughs, ten frames. Reynolds number
|
||||
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
|
||||
@@ -72,14 +72,6 @@ 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
|
||||
streamlines seeded upstream of it, coloured by speed (`results.streamlines`).
|
||||
|
||||

|
||||
|
||||
**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`)
|
||||
|
||||

|
||||
@@ -208,22 +200,3 @@ SolidCraft geometry with persistent face names, swept all-hex (hex8 of 4 mm, inc
|
||||
clamped at `F2:start` and loaded with 1000 N down through a kinematic coupling to `F2:top`.
|
||||
**Look at:** the bending stress in the flanges, highest at the clamp; the tip moves 0.18 mm down
|
||||
(beam theory with shear: 0.184 mm).
|
||||
|
||||
## Heated flow past a bracket (`heated-flow`)
|
||||
|
||||
Air at 20 °C enters at 1 mm/s and passes the original upright bracket, whose faces are
|
||||
held at 80 °C. The 130 × 80 × 70 mm domain has 5 mm cells (5,824 cells), a pressure outlet,
|
||||
slip sides and top, and a no-slip floor. There are 180 steps and six saved output intervals,
|
||||
plus the initial frame. Open `file.sample {"name":"heated-flow"}` and solve on CPU or GPU.
|
||||
The slow prescribed flow keeps this coarse lattice away from its small-relaxation-time limit.
|
||||
|
||||
**Look at:** Results → Temperature, or `results.slice {"field":"temperature"}`, for the
|
||||
warming air. `results.surface {"field":"wall_heat_flux"}` shows heat received by each
|
||||
bracket face per area: the hot wall heating the air is negative. In this model's mm–tonne–second
|
||||
units the wall flux is mW/mm². The initial frame has no recorded wall heat and stays uncoloured.
|
||||
|
||||
**Check:** the fluid warms above its inlet temperature while staying below the held wall
|
||||
temperature (with a small numerical tolerance); recorded hot walls give heat to the fluid.
|
||||
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
|
||||
`crates/engine/src/sample/heated_flow.rs` and its gallery registration.
|
||||
+1
-1
@@ -160,7 +160,7 @@ the control channel and AI agents over MCP (docs/mcp.md) run the same commands.
|
||||
| Plots | Path Plot — Plot a quantity along a path | coming soon (M9) | |
|
||||
| Plots | Cut — Section cuts through the model | coming soon (M9) | |
|
||||
| Plots | Display Group — Show and hide parts of the model | coming soon (M9) | |
|
||||
| Plots | Streamlines — A fluid step's velocity, pressure or temperature on slices and obstacles, speed or temperature along streamlines, and signed wall heat per area | ready | `results.streamlines`, `results.slice`, `results.surface` |
|
||||
| Plots | Streamlines — A fluid step's streamlines coloured by speed, its velocity or pressure on a slice, and the obstacles coloured by the flow | ready | `results.streamlines`, `results.slice`, `results.surface` |
|
||||
| Files | Export VTU — Write a solved step as VTK unstructured grids for ParaView | ready | `results.export.vtu` |
|
||||
| Files | Read Results — Read results in public formats | coming soon (M9) | |
|
||||
| Files | Convergence — Mesh convergence studies | coming soon (M9) | |
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 70 KiB |
Binary file not shown.
+1
-11
@@ -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.
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
## Theory: closed-form solutions, patch tests and mesh invariants
|
||||
|
||||
@@ -293,12 +293,6 @@ 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-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-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 |
|
||||
| [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 |
|
||||
@@ -341,10 +335,6 @@ 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-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-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-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)
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
# FieldCraft Remote: a distributed, asynchronous simulation backend
|
||||
|
||||
**Status:** design proposal, October 2026, open for feedback. Try the flow in the [clickable prototype](https://claude.ai/code/artifact/ee7dfa05-ce8f-49a3-95a4-05ca7616e0f5#page-dcb81aee8d83).
|
||||
|
||||
## Summary
|
||||
|
||||
FieldCraft Remote lets you submit a solve from the desktop app, the browser, the CLI or an AI agent, disconnect, and collect the results later from any device. A stateless switchboard, the **hub**, connects your clients to your **workers**: the same headless FieldCraft engine the CLI already runs. One worker also acts as the cluster's coordinator and keeps the job log. Results stay on the worker that made them, and the client claims them through the hub whenever it next connects.
|
||||
|
||||
The hub is a service we host at `hub.solidcraft.io`, and the same binary is self-hostable. There are no accounts: a **cluster** is a secret seed passphrase. Every machine and client given the passphrase joins the same cluster and sees its jobs, and everything that passes through the hub is encrypted with a key derived from it, so the hosted hub never sees your models or results. The hub stores nothing at all, not even a database: it keeps only live connections in memory, and a restart costs no work. Jobs live on your coordinator, and results stay on your workers until a client claims them.
|
||||
|
||||
The compute is always your own. What changes between setups is only how many workers join:
|
||||
|
||||
- **One workstation.** Run `fieldcraft worker --join "<passphrase>"` on a machine with a GPU. Submit from the laptop or the browser anywhere; the workstation picks the jobs up.
|
||||
- **A fleet.** Run the same command on every spare machine. Each advertises its cores, memory and GPUs and takes the jobs that fit.
|
||||
- **Kubernetes.** A Helm chart runs worker pods per node pool (CPU, GPU, high memory), with the passphrase as a Kubernetes Secret, scaled on the cluster's queue depth.
|
||||
|
||||
Self-hosting means running `fieldcraft hub serve` on your own server and pointing clients at it with `--hub <url>`; nothing else changes.
|
||||
|
||||
Asynchronous work is the design centre: jobs outlive the client session, every state change is recorded, and clients sync results rather than stream them. You can check in whenever you reconnect, from any device, and it fetches what finished while you were away.
|
||||
|
||||
## Why: what the AIO cooler study showed
|
||||
|
||||
A realistic study is a few hours of independent solves stuck in a serial queue on one laptop. On 17 October we modelled a 240 mm AIO cooler: a lattice Boltzmann (LBM) sweep of the radiator, then a steady heat solve per cold plate for each fan setup, on a Core Ultra 7 155H with its integrated Arc GPU.
|
||||
|
||||
| Stage | Size | Wall time | Where it ran |
|
||||
| --- | --- | --- | --- |
|
||||
| Tet mesh, skived cold plate | 796k tets, single-threaded | 214 s | CPU, 1 core |
|
||||
| Tet mesh, pin-fin cold plate | 579k tets around 261 pins | 15 min | CPU, 1 core |
|
||||
| Steady heat solve, one step | 184k dofs, 133M factor entries | 60 s | CPU, 16 threads |
|
||||
| LBM radiator cell, 1.6 m/s | 4.4M cells, 17k steps | 1086 s | Arc iGPU |
|
||||
| LBM radiator cell, 0.4 m/s | 4.4M cells, 33k steps | 1732 s | Arc iGPU |
|
||||
| LBM runs lost to instability or a wrong time step | 5 runs | about 1.5 h | Arc iGPU |
|
||||
|
||||
What hurt:
|
||||
|
||||
- **Independent work ran serially.** The LBM velocities and the 3 cold plates × 3 fan setups depend on nothing but their inputs, yet they ran one after another on one GPU and one CPU.
|
||||
- **The laptop had to stay up.** Every solve lived inside a local MCP session; closing the lid would have lost hours.
|
||||
- **Orphans and hand-made caches.** Stopping a run left a headless `fieldcraft-cli` still holding the GPU, unnoticed for 48 minutes. Each script cached its own results in JSON next to the models.
|
||||
- **Feedback came only at the end.** A bad LBM time step showed up as an energy-balance failure after 33 minutes, not as a trend in the first few.
|
||||
|
||||
A cluster node with a discrete GPU runs these LBM cells many times faster than an iGPU. Ten workers would finish the 9-case sweep in about the time of its slowest case.
|
||||
|
||||
## Goals and non-goals
|
||||
|
||||
**Goals**
|
||||
|
||||
1. **Run anywhere, same API.** One hub binary serves a single workstation, a fleet, or a Kubernetes cluster. Clients cannot tell which.
|
||||
2. **Asynchronous by default.** Submitting returns a job id at once. Jobs survive client disconnects, hub restarts and worker loss. Results wait on the workers until a client claims them.
|
||||
3. **Every client.** The desktop app, the browser app, `fieldcraft-cli` and MCP agents submit, watch, cancel and fetch through the same calls.
|
||||
4. **Bit-for-bit results.** A remote solve gives the same numbers as a local one, because the worker is the same engine and the solvers do not depend on thread count.
|
||||
5. **Studies, not just solves.** Parameter sweeps and chained stages (radiator → coolant temperature → cold plate) are first-class, run in parallel, and reuse finished work by content hash.
|
||||
6. **Right hardware per stage.** A mesh job goes to a CPU worker, an LBM job to a GPU worker, and a big factorisation to a high-memory node.
|
||||
7. **Visible progress, early failure.** Live progress, convergence histories and engine warnings stream to the hub. A study can stop a job that is diverging.
|
||||
|
||||
**Non-goals**
|
||||
|
||||
- Distributing one solve across nodes (MPI, domain decomposition). Each job runs on one machine; the speed-up comes from running many jobs at once.
|
||||
- A general HPC scheduler. On a cluster the hub hands scheduling to Kubernetes; on a workstation it runs a simple local queue.
|
||||
- Selling compute. The hosted hub only relays encrypted bytes; solves run, and results stay, on machines the cluster's owner joined.
|
||||
|
||||
## What FieldCraft already gives us
|
||||
|
||||
Most of the hard parts exist; this proposal adds a transport and a queue, not a new engine.
|
||||
|
||||
| Existing piece | Where | What the remote backend uses it for |
|
||||
| --- | --- | --- |
|
||||
| Command engine: every capability is a command with a JSON Schema and declared `requires` | `crates/engine`, `fieldcraft-cli commands --json` | The job payload is a list of commands, validated before it is queued |
|
||||
| `.fieldcraft` package: model, mesh, journal, SHA-256 per part, design referenced by hash | `docs/formats/fieldcraft.md` | The job's input bundle, content-addressed and deduplicated |
|
||||
| Same bytes for the same model on every platform | `fieldcraft-io` | Content hashes are stable, so the cache key is reliable |
|
||||
| Results independent of thread count (`fieldcraft-parallel`) | `docs/adr/0001` | A remote solve matches a local one |
|
||||
| Jobs with progress and cancellation (`job.status`, `job.wait`, `job.cancel`) | `docs/control-protocol.md` | Progress for the hub, cancellation that stops at the next increment |
|
||||
| Solver caps: `max_dofs`, `max_cells`, `max_cell_updates`, `max_factor_entries`, `threads` | `solve.*` commands | Resource estimates for scheduling, and hard limits per worker |
|
||||
| GPU LBM backend (`backend: "gpu"`, `FIELDCRAFT_GPU`) | `fieldcraft-lbm-gpu` | GPU jobs land on GPU nodes; the CPU backend is the deterministic fallback |
|
||||
| VTU/PVD export, CSV probes, heat and force histories | `results.export.*`, `solve.probe` | The result artefacts clients fetch and plot |
|
||||
| MCP server with path confinement (`--automation-read-root`, `--automation-write-root`) | `fieldcraft-cli mcp` | The worker's sandbox: a job reads only its inputs and writes only its outputs |
|
||||
| Browser build with the same engine (wasm, WebGPU) | `apps/fieldcraft-web` | A browser client for small solves and for viewing results; large ones go to the hub |
|
||||
|
||||
What is missing: a result format the app can open without re-solving, since a saved `.fieldcraft` holds the model but not its results. Today a remote result would have to come back as VTU, or be solved again. The first milestone adds a results part to the package (see Rollout).
|
||||
|
||||
## Architecture
|
||||
|
||||
There are three parts: clients that submit and claim, a hub that only switches, and your workers, one of which coordinates. The hub never runs a solve, never holds a key and never writes to disk.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph clients["Your clients"]
|
||||
D["Desktop app"]
|
||||
B["Browser app"]
|
||||
C["fieldcraft CLI"]
|
||||
M["MCP agents"]
|
||||
end
|
||||
subgraph hub["Hub: in memory only (hub.solidcraft.io or self-hosted)"]
|
||||
S["Switchboard: checks keys, routes by cluster"]
|
||||
P["Presence: who is connected right now"]
|
||||
R["Relay: forwards encrypted frames"]
|
||||
Q["Short buffer: 60 s and 4 MB per cluster"]
|
||||
end
|
||||
subgraph workers["Your workers"]
|
||||
Co["Coordinator (one worker): job log, scheduler, cache index"]
|
||||
W["Workstation: CPU slots, 1 per GPU"]
|
||||
F["Fleet machines: fieldcraft worker --join"]
|
||||
K["Kubernetes pools: cpu, gpu, highmem; holder pool keeps results"]
|
||||
end
|
||||
D --> hub
|
||||
B --> hub
|
||||
C --> hub
|
||||
M --> hub
|
||||
Co --> hub
|
||||
W --> hub
|
||||
F --> hub
|
||||
K --> hub
|
||||
```
|
||||
|
||||
Every arrow is an outbound connection to the hub; the hub writes nothing to disk and forgets all on restart. Jobs, logs and results live on your machines, encrypted with k\_data; the hub only forwards them.
|
||||
|
||||
- **Hub, a switchboard.** In memory it holds only who is connected in each cluster, plus a buffer of at most 60 s and 4 MB per cluster for a member that is reconnecting. It checks each connection's signature against the cluster key it presents, forwards encrypted frames between members, and forgets everything when it restarts. Members reconnect within seconds and nothing is lost, because nothing lived there.
|
||||
- **Coordinator.** A role only a worker can take, never an interactive session (the desktop app, the browser, the CLI or an MCP agent). By default it is the worker joined with `--coordinator`: usually the always-on workstation, or a pod in the Kubernetes cluster. It keeps the job log in SQLite, encrypted at rest. It also schedules leases by needs, resolves study dependencies, tracks which worker holds which result, sends notifications and serves the queue metric for KEDA.
|
||||
- **Replicas and failover.** Every worker keeps a copy of the job log, streamed from the coordinator. If the coordinator is gone for 60 s, the online worker with the lowest device id takes over from its copy and announces a new term. Log entries carry the term, so a returning old coordinator steps down instead of writing.
|
||||
- **Worker.** `fieldcraft worker` is the headless engine plus a loop: ask the coordinator for a lease that fits its slots, fetch and decrypt the inputs, run the commands, stream progress, keep the encrypted outputs on its disk, report done. The heartbeat runs on its own thread; a worker silent for 60 s loses its lease and the job is requeued.
|
||||
- **Results on workers.** Each job's outputs stay on its worker's disk, encrypted and named by their keyed ids. A result is a set of ids, so a client claims only the files it lacks.
|
||||
|
||||
**Claiming results.** Result files never rest at the hub; it only relays them.
|
||||
|
||||
- **Live claim.** If a cluster client is connected when a job finishes, the worker streams the outputs to it through the hub. The client checks each keyed id and acknowledges.
|
||||
- **Later claim.** A client that connects later asks the coordinator for the job's results, and the holding worker streams them through the hub.
|
||||
- **After a claim.** Once a cluster client has acknowledged every output, the worker may drop them. It keeps claimed results in a local cache, oldest out first under a disk quota, for re-delivery and study cache hits. Unclaimed results are kept 7 days.
|
||||
- **Small records in the job log.** Specs, inputs up to 64 MB, the summary, the log and progress histories live in the coordinator's job log and its replicas. Every device sees a job's outcome even while its worker is offline.
|
||||
- **Studies.** A job that consumes another job's outputs runs on that job's worker when it fits there; otherwise the outputs go from worker to worker through the hub.
|
||||
- **A worker goes away.** Results on an offline worker wait until it returns. If a worker's disk is lost, its unclaimed jobs run again, with the same result bit for bit.
|
||||
|
||||
**What this costs.** A submit is complete once the coordinator acknowledges it, and from then on the client can disconnect. If no coordinator is online, the client keeps the job and submits it as soon as one appears; it never takes the role itself. A cluster with a workstation that is always on never notices; a cluster of laptops only would. The hub needs no disk and no database, only memory for its connections and bandwidth: every result byte crosses it once per claim.
|
||||
|
||||
**Hub restarts.** A restart, a redeploy or a crash of the hub is an ordinary event that costs no work. Every part is built so that "the hub does not know this" means "not yet", never "gone".
|
||||
|
||||
- **Workers keep running.** A job in progress carries on with the hub down. Its progress events, heartbeats and finished outputs queue on the worker's disk, numbered, and are replayed in order when it reconnects.
|
||||
- **Leases freeze, not expire.** The coordinator counts a worker's silence only while the coordinator itself is connected, and gives every worker a 120 s grace period after any reconnect of its own. A hub outage therefore never requeues a running job.
|
||||
- **The hub says it just restarted.** For its first 120 s the hub answers a request for a cluster it has not seen with `warming` and its uptime, not `unknown`. Clients and workers back off and retry; reconnects are spread with random jitter so a restart does not bring a stampede.
|
||||
- **Clients own their submissions.** A client generates each job's id itself and keeps a local record of every job it sent: id, spec, cache key, last known state. When the coordinator cannot be reached, the app shows those jobs as "waiting for the cluster" with their last known state, not as failed.
|
||||
- **Resubmitting is always safe.** Submit is idempotent by job id. If the coordinator, once back, reports a job id as never seen (for example, it was lost with a disk before it replicated), the client resubmits the same id. A worker that already holds a finished result for that id or its cache key answers at once.
|
||||
- **Nothing waits on the hub's memory.** The 60 s buffer only smooths brief reconnects. Anything that must survive lives on a member and is resent from there.
|
||||
|
||||
## Job lifecycle
|
||||
|
||||
Submitting returns a job id at once; from then on the coordinator owns the job, and the client only checks in.
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
direction LR
|
||||
[*] --> Submitted: client makes the job id
|
||||
Submitted --> Queued: coordinator acknowledges
|
||||
Queued --> Done: cache hit, a worker still holds the result
|
||||
Queued --> Running: leased to a worker that fits
|
||||
Running --> Queued: worker lost for 60 s, requeued
|
||||
Running --> Done: outputs held by its worker
|
||||
Running --> Failed: error, stop rule or cancel
|
||||
Done --> Claimed: client claims at its next check-in
|
||||
```
|
||||
|
||||
Your laptop can be offline anywhere between Submitted and Claimed; the job carries on.
|
||||
|
||||
1. **Submit.** The client validates the commands against the engine's schemas and `requires`, gives the job its own id, encrypts the spec and sends it with its inputs to the coordinator. The coordinator writes it to the job log and acknowledges; from then on the client can disconnect.
|
||||
2. **Queue.** The job waits until its inputs exist (a study's earlier jobs), then checks the cache. A cache hit goes straight to Done.
|
||||
3. **Lease and run.** The coordinator leases the job to a worker whose free slots fit its needs; the worker heartbeats every 10 s. It runs the commands and streams the engine's progress line, warnings and chosen histories (heat, force, residuals) as small encrypted events.
|
||||
4. **Finish.** Outputs stay on the worker's disk, and stream straight to any connected client, and the job is Done, or Failed with the engine's error, or Cancelled. A lease that misses heartbeats for 60 s, counted only while the coordinator is itself connected, is revoked and the job requeued. Solvers restart from the beginning, since only the transient solvers have frames to resume from.
|
||||
5. **Fetch.** On its next check-in a client asks the coordinator for everything changed since its last sync cursor and claims the outputs it wants. A worker holds unclaimed results for 7 days, or until you delete them.
|
||||
|
||||
## Protocol and engine changes
|
||||
|
||||
The hub speaks one protocol: a WebSocket per member at `wss://hub.solidcraft.io/v1/connect`. Everything else is a message between members, inside encrypted frames the hub forwards.
|
||||
|
||||
**Connecting.** The hub sends a random challenge. The member answers with the cluster's public key, its device key and a k\_sign signature over the challenge and the device key. The hub then puts the connection in the room for that cluster id. Nothing is stored, and a reconnect repeats the handshake.
|
||||
|
||||
| Frame the hub reads | From | What the hub does |
|
||||
| --- | --- | --- |
|
||||
| `hello` | Any member | Checks the signature and joins the member to its cluster's room |
|
||||
| `announce {role, term}` (signed) | Coordinator | Routes `to: coordinator` to the highest term announced since the hub started |
|
||||
| `send {to, seq, ciphertext}` | Any member | Forwards to a device id, `coordinator` or `clients`; buffers up to 60 s and 4 MB while the target reconnects, else answers `absent` |
|
||||
| `stream.open`, `stream.chunk`, `stream.close` | Worker or client | Relays a result or input transfer with flow control; a broken stream resumes from its byte offset |
|
||||
| `presence` | Hub | Tells members who joined or left |
|
||||
| `warming {uptime}` | Hub | Its answer, for the first 120 s after a start, about any cluster with no members yet |
|
||||
|
||||
Inside `send`, encrypted end to end with k\_data, members speak their own protocol. Each message carries its sender's device id and a per-device sequence number, so receivers drop replays and duplicates.
|
||||
|
||||
| Message | From → to | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `submit {job id, spec, inputs, needs, deps, cache key}` | Client → coordinator | Idempotent by the client's job id; acknowledged once in the log |
|
||||
| `study {jobs}` | Client → coordinator | A graph of jobs in one message |
|
||||
| `changes {since}` | Client → coordinator | Every job change since the client's sync cursor |
|
||||
| `cancel`, `delete {job id}` | Client → coordinator | Stops a job; tells its holder to drop the results |
|
||||
| `claim {job id, have}` | Client → coordinator | The coordinator asks the holding worker to stream the outputs the client lacks |
|
||||
| `lease {slots, capabilities}` | Worker → coordinator | Returns a lease that fits, or waits |
|
||||
| `heartbeat`, `events`, `done` | Worker → coordinator | Numbered and kept on the worker's disk until acknowledged, so a hub restart replays them |
|
||||
| `log.append {term, entries}` | Coordinator → workers | Replicates the job log |
|
||||
| `handoff {ids}` | Worker → holder | Moves results off a pod before it scales down |
|
||||
|
||||
The cache key is computed by the client, as HMAC(k\_id) over the normalised command list, the input ids and the engine version. The coordinator only compares opaque keys.
|
||||
|
||||
**Engine changes (M1).**
|
||||
|
||||
- **Results part in `.fieldcraft`.** A fifth package part holds the solved steps' fields, so a fetched result opens without solving again.
|
||||
- **`fieldcraft-cli worker`.** Runs each job in a fresh in-process engine session, without MCP, and claims one GPU slot per adapter `FIELDCRAFT_GPU` can open.
|
||||
- **Progress events.** Every solver and mesher reports through one callback: increments, time steps, residuals and the heat and force histories `solve.probe` already exposes. The worker forwards them.
|
||||
- **`solve.estimate`.** A dry run that reports dofs, factor entries, lattice cells, cell updates and peak memory from the existing caps' arithmetic, so the client can declare needs and the scheduler can pick a pool.
|
||||
- **Python steps.** A step `{"python": "reduce.py", "inputs": [...]}` runs with the job's inputs in a folder and writes its outputs to another. The worker image ships Python with numpy, and `uv` for anything else a step declares.
|
||||
|
||||
## Deployment: the hub and your workers
|
||||
|
||||
The hub coordinates and relays; your machines compute and keep the results. Workers and clients only make outbound HTTPS calls to the hub, so a worker behind NAT, a home connection or a cluster with no ingress just works.
|
||||
|
||||
| | Hosted hub | Self-hosted hub |
|
||||
| --- | --- | --- |
|
||||
| Address | `https://hub.solidcraft.io` (the default once it opens, in M5) | Your own URL; clients take `--hub <url>` or the `FIELDCRAFT_HUB` variable |
|
||||
| State on the hub | None: live connections in memory only; the job log is on your coordinator | None, the same |
|
||||
| Result files | None: results stay on your workers | None, the same |
|
||||
| What the hub can read | Who is connected and the sizes and timing of frames, in memory only; never jobs or content | The same (it is the same software) |
|
||||
| Limits | Per-cluster quotas, decided when the public hub opens (M5) | Whatever you configure |
|
||||
| Setup | Nothing | `fieldcraft hub serve`, or the Helm chart's hub |
|
||||
|
||||
| Workers | How they join | Notes |
|
||||
| --- | --- | --- |
|
||||
| One workstation | `fieldcraft worker --join "<passphrase>"` | One slot per GPU adapter, CPU slots by core count; installs as a system service and keeps results on its own disk |
|
||||
| A fleet | The same command on each machine | Each worker advertises cores, memory and GPU adapters and leases only jobs that fit |
|
||||
| Kubernetes | Helm chart: a worker StatefulSet per node pool, each pod with a persistent volume, the passphrase in a Secret | KEDA scales each pool on its queue depth; a pod hands its unclaimed results to the holder pool before it scales down, so the GPU pool still reaches zero |
|
||||
|
||||
**Kubernetes.** Workers are StatefulSets, one per node pool, because a worker's disk is where results live. Each pod has a persistent volume, runs one job at a time, and requests resources to fit its pool: `cpu` (many cores, 64–256 GB), `gpu` (one GPU per pod) and `highmem` for large direct factorisations. A small `holder` pool of cheap CPU pods with large volumes runs no solves; it only keeps results. When KEDA scales a GPU or CPU pod down, the pod first relays its unclaimed and cached results to a holder through the hub, then exits. The GPU pool can then still scale to zero without losing anything. The queue depth KEDA reads is a metrics endpoint on the coordinator pod, inside the cluster; it never goes through the hub.
|
||||
|
||||
GPU pods need a Vulkan driver inside the image, because wgpu compute runs on Vulkan on Linux. On NVIDIA that means the container toolkit with the `graphics` driver capability, which we need to verify on our nodes. The image also carries Mesa for Intel and AMD nodes.
|
||||
|
||||
## Studies, sweeps and caching
|
||||
|
||||
A **study** is a small graph of jobs whose inputs can be outputs of earlier jobs. The coordinator runs every job whose inputs are ready, so independent branches run side by side. The AIO study as a graph:
|
||||
|
||||
1. `mesh` × 3 (one per cold plate, CPU) and `lbm` × 3 (one per velocity, GPU) start together.
|
||||
2. A `reduce` step reads the LBM rows (pressure loss, heat per wall) and computes the fan operating points and coolant temperatures. It is a small script job on any CPU worker.
|
||||
3. `heat` × 9 (3 plates × 3 fan setups) start as each mesh and the reduce step finish.
|
||||
4. `render` × 9 write the cutaways; a `report` job gathers the table.
|
||||
|
||||
With workers to spare, the wall time is the slowest LBM run plus one heat solve. On the laptop it was every run in sequence.
|
||||
|
||||
**Study definition.** A study is a JSON file next to the model: named parameters with their values, and jobs with `inputs` (artefact hashes, or `{"from": "<job>", "output": "<name>"}`), the commands to run, and declared needs (`gpu`, cores, memory). A sweep is a parameter with a list of values. `fieldcraft study run aio.study.json` submits it; the app gets a Studies panel that does the same.
|
||||
|
||||
**Caching by content hash.** A job's key is the SHA-256 of its command list, its input artefacts' hashes and the engine version. A job whose key matches a result some worker still holds is answered from that worker without running. Rerunning the AIO study after changing only the fan curve would then redo only `reduce`, `heat` and `render`. The LBM and mesh jobs, the 2 hours of compute, come straight back, as long as their workers still hold them.
|
||||
|
||||
**Early stopping.** A job can declare a stop rule over a streamed quantity, for example "cancel if the heat history's late-window mean breaks the m·cₚ bound". The worker evaluates it every progress tick and cancels through the engine's `job.cancel`, so a bad time step costs minutes, not half an hour.
|
||||
|
||||
## Security: the passphrase is the cluster
|
||||
|
||||
There are no accounts or logins. A cluster is created by `fieldcraft cluster new`, which prints a seed passphrase of 12 random words (132 bits). Anyone holding those words is a full member: they can join workers, submit and cancel jobs, read every result and run code on every worker. There is one passphrase per cluster, with no lesser roles. The hub holds none of the keys.
|
||||
|
||||
**What the words become.** Every client derives the same keys locally:
|
||||
|
||||
```latex
|
||||
\begin{aligned}
|
||||
m &= \mathrm{Argon2id}(\text{words},\ \text{salt} = \texttt{fieldcraft-cluster-v1}) \\
|
||||
(k_{\mathrm{sign}},\ k_{\mathrm{data}},\ k_{\mathrm{id}}) &= \mathrm{HKDF}(m) \\
|
||||
\text{cluster id} &= \mathrm{SHA256}(\mathrm{pub}(k_{\mathrm{sign}}))
|
||||
\end{aligned}
|
||||
```
|
||||
|
||||
- **k\_sign** (Ed25519) signs every request to the hub: a timestamp, a nonce and the body hash. The hub stores nothing: each connection presents the public key and proves it holds k\_sign by signing the hub's challenge.
|
||||
- **k\_data** encrypts every job spec, input bundle, result, log and progress line (XChaCha20-Poly1305) before it leaves the machine. The hub cannot read or alter them undetected.
|
||||
- **k\_id** keys the artefact ids: an artefact's id is HMAC(k\_id, plaintext). Deduplication and the result cache work inside a cluster, but the hub cannot tell whether two clusters hold the same model.
|
||||
|
||||
**What the hub sees.** It sees, in memory and only while they last: the cluster id, which devices are connected, who sends frames to whom, and their sizes and timing. It never sees a job, a need, a state, a file name or a number, because job records and scheduling live on the coordinator, inside encrypted frames. Nothing it sees is written down.
|
||||
|
||||
**Words must be random.** A cluster id is public to the hub, so a human-chosen passphrase could be guessed offline. The CLI and app only accept words they generated. The 132 bits plus Argon2id put guessing out of reach.
|
||||
|
||||
**Devices and revocation.** Each worker and client also makes its own device key and registers it, signed with k\_sign, so the cluster's device list shows what joined and when. A lost laptop is removed from the list, but its copy of the words still works. A real revocation is a rotation: `fieldcraft cluster rotate` makes new words, re-encrypts what you keep under the new keys, and leaves a signed forwarding record so other devices can follow with the new words.
|
||||
|
||||
**Lost words cannot be recovered.** We cannot reset them, and nobody can read the cluster's data without them. Clients keep the derived keys in the OS keychain (in the browser, a non-extractable WebCrypto key in IndexedDB), so the words are typed once per device.
|
||||
|
||||
**What a worker will run.** A job is a list of FieldCraft engine commands, plus optional Python steps such as `reduce` and `report`. Joining a cluster means accepting its code: there is no opt-in flag, and anyone with the words can run Python on every worker. So the passphrase is a root credential for those machines. A worker runs as its own unprivileged user (on Kubernetes, a non-root pod with no service-account token), each job in a fresh scratch folder that is wiped afterwards. The engine commands also keep the MCP server's path confinement, which bounds what a mistyped path can touch.
|
||||
|
||||
**The hosted hub's own exposure.** There is nothing to register, so anyone with a fresh passphrase has a cluster. `hub.solidcraft.io` limits each cluster's connections, buffer memory and relay bandwidth, and refuses frames over a set size. A compromised hub could drop, delay or reorder frames and watch traffic patterns. It could not read or forge a job or a result, and it could not lose work, since none is kept there.
|
||||
|
||||
## Client experience
|
||||
|
||||
Every client does the same five things: join, submit, watch, cancel and fetch. The difference is only where you sit.
|
||||
|
||||
| Client | Join | Submit and watch | Results |
|
||||
| --- | --- | --- | --- |
|
||||
| Desktop app | Settings › Cluster: paste the words once | Solve runs on **This computer** or **Cluster**; a Jobs panel shows each job's progress line, warnings and convergence plot, with Cancel | Synced in the background into a local cache; a finished result opens like a local one |
|
||||
| Browser, `hub.solidcraft.io/app` | Paste the words; the keys stay in the browser | Small models solve in the page (wasm, one thread); larger ones go to the cluster | Downloaded into the browser's private file storage (OPFS), viewed in the page, saved as a file on request |
|
||||
| `fieldcraft` CLI | `fieldcraft cluster join` | `fieldcraft submit model.fieldcraft --step heat`, `fieldcraft study run aio.study.json`, `fieldcraft jobs`, `fieldcraft cancel <job>` | `fieldcraft fetch <job> --out results/` |
|
||||
| MCP agents | The server is started with the cluster joined | Tools `submit_job`, `job_status`, `cancel_job` beside today's `execute` and `batch` | `fetch_results`, then the usual `solve.probe` and `results.*` on the fetched model |
|
||||
|
||||
**Checking in later.** You schedule a study and disconnect. Workers keep going; nothing depends on the device you scheduled from. When you next open the app, on that device or another, it asks the coordinator what changed since its last sync. It shows something like "AIO study: 15 of 16 jobs done, 1 failed", and starts fetching in the background. Results under a size limit you set (say 2 GB) download automatically; bigger ones wait until you open them. Everything fetched stays in the local cache for offline viewing and saving.
|
||||
|
||||
**What comes back.** Each solved job returns a `.fieldcraft` with a results part, which the app opens without solving again. It also returns the exports the job asked for (VTU or PVD for ParaView, CSV probes), its log and its progress history. These are the same files the AIO study's scripts collected by hand.
|
||||
|
||||
**Notifications (optional).** A cluster can name a webhook or an ntfy topic. The coordinator posts only a job id and state there, so the message carries nothing that needs the key; the phone app shows "study finished" and the details open in the client.
|
||||
|
||||
## Prototype: schedule a study, check in later
|
||||
|
||||
A clickable mockup of the desktop journey is in the [FieldCraft Remote CUJ prototype](https://claude.ai/code/artifact/ee7dfa05-ce8f-49a3-95a4-05ca7616e0f5#page-dcb81aee8d83). It uses the compact Craft shell from SolidCraft's ribbon redesign (tangency-labs/solidcraft#1439) with FieldCraft's own workspaces, commands and icons. Press Play on the artboard; the step bar above the window jumps between steps.
|
||||
|
||||
1. **Join cluster.** Analysis › Solve › Remote › Join Cluster opens a dense panel for the 12 words, the hub and the device. The app-bar chip turns from "No cluster" to "harbor-velvet · 3 workers".
|
||||
2. **Schedule study.** Schedule Study shows aio.study.json with a This computer / Cluster switch, its six stages and the stop rule. Once the coordinator acknowledges, the device can disconnect, and View progress opens the cluster view.
|
||||
3. **Running.** The cluster view shows slots fill and load change while the scheduling device is offline; a simulated-time control in the prototype advances the clock. A phone notification arrives when the study finishes.
|
||||
4. **Check in.** The hub-warming banner ("waiting for the cluster, not failed") gives way to the sync. The Browser becomes the job tree, the panel shows the selected job's residuals and warnings, and the failed heat job offers "Rerun with dt × 0.5". Results under 2 GB fetch in the background.
|
||||
5. **Open result.** The Results workspace opens the fetched .fieldcraft from the local cache with contours, without solving again.
|
||||
|
||||
**Joining a cluster.** The Join Cluster panel in Analysis › Solve, words pasted, before Join. The chip in the app bar still reads "No cluster".
|
||||
|
||||
*Screenshot in the [prototype](https://claude.ai/code/artifact/ee7dfa05-ce8f-49a3-95a4-05ca7616e0f5#page-dcb81aee8d83): the Join Cluster panel in the Analysis workspace.*
|
||||
|
||||
**Jobs in progress.** Three hours after scheduling, with the scheduling device offline. The heat stage holds all 9 CPU slots at 100% while the GPU slots sit free, and every worker keeps its finished results on its own disk.
|
||||
|
||||
*Screenshot in the [prototype](https://claude.ai/code/artifact/ee7dfa05-ce8f-49a3-95a4-05ca7616e0f5#page-dcb81aee8d83): the cluster view during the heat stage, with the scheduling device offline.*
|
||||
|
||||
**Downloading results.** Checked in later: 24 of 26 jobs done. Results under 2 GB download on their own; the 6.2 GB of LBM fields were fetched by hand. The failed heat job shows why the stop rule cancelled it.
|
||||
|
||||
*Screenshot in the [prototype](https://claude.ai/code/artifact/ee7dfa05-ce8f-49a3-95a4-05ca7616e0f5#page-dcb81aee8d83): the Jobs tab after the sync, with the background fetch card.*
|
||||
|
||||
| Surface | Where it lives in the app | What it shows |
|
||||
| --- | --- | --- |
|
||||
| Cluster chip | App bar, where SolidCraft shows the agent status | Cluster name and workers, running jobs, or hub warming; opens the cluster view |
|
||||
| Remote section | Analysis › Solve ribbon | Join Cluster, Schedule Study, Jobs |
|
||||
| Cluster view | Full-page view, like the Start page | Free GPU and CPU slots, memory and held results; client → hub → workers; one card per worker with its slot tiles, CPU, memory and disk; the queue by need |
|
||||
| Jobs tab | Analysis workspace, in place of Solve | Job tree by stage in the Browser, job detail with convergence in the panel, background fetch |
|
||||
|
||||
The hardware and load figures in the prototype are illustrative, and its cluster and job icons are sketches in the icon palette, not final artwork.
|
||||
|
||||
## Rollout and decisions
|
||||
|
||||
Ship and prove the self-hosted path first, using the AIO study as the acceptance test at every step; the public hub comes last.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
M1["M1 Remote solve<br/>results in .fieldcraft · coordinator, hub serve<br/>cluster words, keys · submit, jobs, claim CLI<br/><b>Gate:</b> AIO study on a self-hosted hub<br/>matches local runs bit for bit"]
|
||||
M2["M2 Asynchronous app<br/>Jobs panel, cancel · background sync, cache<br/>browser joins by words · claims after reconnect<br/><b>Gate:</b> submit, 4 h offline, fetch on landing"]
|
||||
M3["M3 Studies<br/>study files and graph · keyed result cache<br/>stop rules on histories · MCP job tools<br/><b>Gate:</b> a fan-curve change reruns<br/>only reduce and heat"]
|
||||
M4["M4 Kubernetes<br/>Helm chart, KEDA pools · GPU image with Vulkan<br/>holder pool on volumes · coordinator pod<br/><b>Gate:</b> 9-case sweep in about<br/>its slowest case's time"]
|
||||
M5["M5 Public hub<br/>hub.solidcraft.io opens; its quotas are set then<br/><b>Gate:</b> self-hosted clusters have proven the design"]
|
||||
M1 --> M2 --> M3 --> M4 --> M5
|
||||
```
|
||||
|
||||
M1 also covers the engine changes the backend needs: a results part in `.fieldcraft`, progress events from every solver, and a `worker` mode in `fieldcraft-cli`. The public hub at hub.solidcraft.io is the last deliverable (M5). It opens only once self-hosted clusters have proven the design, and its per-cluster quotas (connections, buffer memory, relay bandwidth) are decided then.
|
||||
|
||||
**Decisions**
|
||||
|
||||
| Question | Decision |
|
||||
| --- | --- |
|
||||
| How long a worker keeps unclaimed results | Workers hold unclaimed results 7 days; copies a client claimed stay on that client |
|
||||
| More than one passphrase per cluster | No: one passphrase, full access |
|
||||
| Relay results straight to a client | Yes, and further: the hub stores no result files at all. Workers hold results until a client claims them, relayed through the hub |
|
||||
| Lease timeout | 60 s of missed heartbeats; the heartbeat runs on its own worker thread, so a factorisation silent for minutes keeps its lease |
|
||||
| Script steps | Python, with no opt-in flag: joining a cluster means accepting its code |
|
||||
| State on the hub | None: no database, no disk. The hub is an in-memory switchboard; the job log lives on a coordinator worker, replicated to the other workers |
|
||||
| Hub restarts | Cost no work: workers keep running and replay, leases freeze, and clients treat a job the hub doesn't know as "not reconnected yet" |
|
||||
| Who can coordinate | Only a worker (`fieldcraft worker`), never an interactive session: the desktop app, the browser, the CLI or an MCP agent. With no worker online, clients hold their jobs until one appears |
|
||||
|
||||
**Still open**
|
||||
|
||||
- [ ] Which NVIDIA driver capabilities do wgpu compute pods need on our nodes, and does the Intel or AMD path need anything beyond Mesa?
|
||||
@@ -1,107 +0,0 @@
|
||||
# Units inventory (#579)
|
||||
|
||||
Every place a dimensional number enters or leaves FieldCraft today, the unit it is assumed to be
|
||||
in, and where the SI conversion goes. Today nothing converts: every stored `f64` means "whatever
|
||||
consistent system the user chose", labelled by `model.units` (`UnitSystem`,
|
||||
`crates/model/src/units/mod.rs`). The SI table that later steps convert through is
|
||||
`crates/model/src/units/` (`Quantity`, `Unit`, `DisplaySystem`). **Risky** marks an implicit
|
||||
assumption, a hard-coded factor or a missing dimension.
|
||||
|
||||
## Model storage (`crates/model/src`): all bare `f64`, no unit tag
|
||||
Conversion goes at the command/file boundary; storage becomes SI.
|
||||
|
||||
| Where | Fields | Quantity |
|
||||
|---|---|---|
|
||||
| `lib.rs` | `units: UnitSystem` (serde default `unspecified`); `MAX_COORD = 1e9` "model units" | label; length |
|
||||
| `mesh.rs`, `datums.rs`, `constraints.rs`, `relaxations.rs`, `mesh_plans.rs` | node/datum/reference point xyz, plan centre/radius/size/tolerance | length |
|
||||
| `sizes.rs`, `seeds.rs`, `virtual_faces.rs`, `tie.rs` | size regions, seed sizes, tolerances, position_tolerance | length (bias, growth: dimensionless) |
|
||||
| `materials.rs` | young, shear, hyperelastic c01/c20/c30, yield_stress, hardening_modulus, hardening table stress, JC a/b | pressure |
|
||||
| | poisson, strains, JC n/c/m, taylor_quinney, damage d1–d5 | dimensionless |
|
||||
| | JC reference_strain_rate | frequency (1/s) |
|
||||
| | JC room/melt temperature, thermal reference_temperature | temperature (absolute) |
|
||||
| | conductivity, specific_heat, expansion | conductivity, specific heat, expansion (1/ΔT) |
|
||||
| | density; fluid viscosity (doc: "1e-9 MPa·s for water") | density; dynamic viscosity |
|
||||
| `sections.rs` | profile dims, shell thickness/offset, 2D thickness, area | length, area |
|
||||
| `loads.rs` | force, moment, traction, pressure, gravity, body force, centrifugal ω/point, surface flux, film, sink, heat source power, temperature | force, moment, pressure, acceleration, force/volume, angular velocity, heat flux, HTC, temperature, heat generation |
|
||||
| `bcs.rs` | displacement `[Option<f64>; 6]` (3 lengths + 3 rotations rad), temperature | length, angle, temperature |
|
||||
| `steps.rs` | frequency min/max_hz, buckle shift, end_time/increments/intervals, initial velocity, rigid wall point, hourglass/bulk viscosity coefs, heat theta/initial_temperature, imperfection scale, displacement control | frequency, time, velocity, length, temperature (coefs dimensionless) |
|
||||
| `contact.rs` | μ static/kinetic, penalty_scale; **decay (time per length)** | dimensionless; s/m |
|
||||
| `discrete.rs` | point mass, inertia, spring stiffness, dashpot coefficient | mass, mass·length², stiffness, damping |
|
||||
| `fluids.rs` | wall/inlet velocity, pressure, thermal temperature/flux, initial temperature, gravity, β, domain min/max/spacing/tolerance | velocity, pressure, temperature, heat flux, acceleration, expansion, length |
|
||||
|
||||
**Risky:** `units` missing in a file means `unspecified`, so a migration cannot know the system of
|
||||
old files without one; the step cache key includes `units`.
|
||||
|
||||
## Commands and forms (`crates/engine/src/cmd`, `schema.rs`, `cmd/forms.rs`)
|
||||
Definition commands (`model.{material,section,load,boundary_condition,…}.{create,edit}`) deserialize
|
||||
JSON straight into the model structs (`cmd/define.rs`), so every number is a bare `f64`. Each form
|
||||
field carries a `Dim` (`schema.rs:299-418`); `Dim::unit(system)` only builds a label. Conversion
|
||||
goes where the form value is read: `Field` → `Quantity` and `ValueInput::to_si`.
|
||||
|
||||
**Risky:**
|
||||
- `Dim::Millimetre` is a fixed "mm" for meshing and geometry commands (tet/hex size, tolerances,
|
||||
datums, hex project, defeature, mesh plan, geometry check), while the same quantities in mesh
|
||||
editing commands (node add/move/relax, seeds, size regions, block, mirror, planar, stitch) are
|
||||
`Dim::Length` and follow the system. One physical quantity, two assumptions.
|
||||
- Missing dimensions: load heat_source power (`forms.rs:561`, really W/m³), frequency shift
|
||||
(`forms.rs:2024`, really 1/s²), contact decay (`forms.rs:1015`, s/m), JC reference strain
|
||||
rate (`forms.rs:137`, 1/s).
|
||||
- `Dim::Temperature` has no unit; `Dim::Expansion` is fixed "1/K" while temperatures are unlabelled.
|
||||
- JSON Schema (`schema.rs:1260-1365`, list_commands, `fieldcraft-cli commands --json`, MCP) emits
|
||||
only `x-dimension` names and none for vectors and number_or_auto (forces, positions, gravity,
|
||||
velocities lose their dimension).
|
||||
- Help/defaults assume mm-t-s: `forms.rs:520` (gravity −9810), `define.rs:98` (210000, 7.85e-9),
|
||||
`define.rs:291`; samples `engine/src/sample.rs:362` `GRAVITY = 9810.0` and aluminium
|
||||
70000 / 2.7e-9.
|
||||
- `model.units` (`cmd/model.rs:39-54`) only sets the label; it warns about mm geometry only while a
|
||||
design is open.
|
||||
|
||||
## UI display (`crates/ui-egui`)
|
||||
Forms (`form.rs`) label through `Field::unit_label/help_text`; descriptions (`describe.rs`),
|
||||
replies (`reply.rs`), results panel and probes (`results_panel.rs`, `lib.rs` `probe_rows`) through
|
||||
`Quantity::unit(system)`. Display conversion goes here (`Quantity::format` with the display
|
||||
system). **Risky:** literal "mm", "mm²", "mm³" in `reply.rs:398, 708, 720, 723, 770` and
|
||||
`feature_graph.rs:52-257`.
|
||||
|
||||
## Results (`crates/engine/src/results.rs`, `flow.rs`, `cmd/xy.rs`, `cmd/results.rs`)
|
||||
`Field::quantity()` maps fields to the engine's `Quantity` (Length, Stress, Force, Moment, Mass,
|
||||
Time, Velocity, Temperature, Energy, HeatFlow, HeatFlux); `Quantity::unit` builds the label, with
|
||||
per-system strings for energy/power/flux. Reactions and probe replies are numbers only; contour
|
||||
replies carry `"unit"`. **Risky:** temperature is always `"°C or K"` (also in in-lbf-s);
|
||||
`ContactGap` (a length) maps to `None`. Conversion: results stay SI; legends and replies format.
|
||||
|
||||
## Files
|
||||
| Format | Today | Conversion |
|
||||
|---|---|---|
|
||||
| `.fieldcraft` (`crates/io`, `FORMAT = "fieldcraft/1"`) | `model.json` numbers as entered plus `"units"`; `mesh.bin` raw coordinates | scale to SI once on open by the declared system. **Risky:** FieldCraft has no pending-format mechanism (only the `FORMAT` const and its refusal); SI storage needs a format version step. Files with `unspecified` need a rule (assume mm-t-s-N-MPa, since geometry is mm). |
|
||||
| Abaqus / Nastran / LS-DYNA (`crates/decks`, write only) | unit label in a comment only | write in a chosen consistent system (`DisplaySystem` factors). **Risky:** no Nastran `PARAM,WTMASS`, no LS-DYNA `*CONTROL_UNITS`; there are no deck readers yet. |
|
||||
| SolidCraft geometry (`crates/geometry`) | "lengths are millimetres" (`lib.rs:16, 96`) | ×1e-3 once at import |
|
||||
| 3MF (`geometry/src/tagged_mesh.rs:151-161`) | **the one real conversion outside the table**: micron/mm/cm/inch/foot/metre → mm; writes `unit="millimeter"` | move factors to the table, convert to m |
|
||||
| STEP | no import exists | — |
|
||||
| STL export (`io/src/stl.rs`) | raw coordinates | write in the display length (STL has no unit field) |
|
||||
| VTU/PVD (`io/src/vtk.rs`) | no unit metadata | add units in field data |
|
||||
| CSV (`cmd/results.rs:1023-1075`) | bare headers; reply has `"units"` | units in headers (`fx [N]`) |
|
||||
|
||||
## CLI and MCP
|
||||
`crates/mcp/src/server.rs:26-27` instructions: "unit-consistent and never converts … use
|
||||
mm-t-s-N-MPa"; `tools.rs:105` example `model.units`; screenshot distance "in model units". The
|
||||
CLI adds no units itself (commands schema as above). Replies switch to values with units.
|
||||
|
||||
## Solvers
|
||||
- LBM (`crates/lbm/src/setup.rs`): lattice units Δx = Δt = ρ₀ = 1; ν, velocities, accelerations,
|
||||
pressure, force, heat scale through h, dt, ρ, c. Self-consistent in any system, no fixed
|
||||
factors; unchanged by SI storage. Temperatures pass unscaled (only differences matter).
|
||||
- Implicit, explicit, fem: no unit literals; thermal strain uses α(T − T_ref), Johnson–Cook uses
|
||||
room/melt in one scale: correct in kelvin. No 273.15 or Stefan–Boltzmann anywhere (radiation
|
||||
would need absolute kelvin, which SI storage gives).
|
||||
|
||||
## Literal factors
|
||||
`cargo xtask unit-factors` lists float literals equal to common factors outside the table: 548
|
||||
today (mesh 187, engine 175, geometry 34, render 33, fem 25, …). Almost all are tolerances,
|
||||
limits or bytes→MB; the real conversions are the 3MF table and the mm-t-s sample constants.
|
||||
|
||||
## Implicit "geometry in mm"
|
||||
Mesh coordinates come from mm geometry whatever `model.units` says: an m-kg-s or in-lbf-s model
|
||||
reads mm numbers as metres or inches; the only guard is the `model.units` warning. SI storage
|
||||
fixes this at the import boundary. `MAX_COORD` is "1e9 model units" in code, "±10⁹ mm" in
|
||||
`docs/formats/tagged-mesh.md:45`.
|
||||
@@ -1,414 +0,0 @@
|
||||
// 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,
|
||||
};
|
||||
@@ -1,331 +0,0 @@
|
||||
'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 }), []);
|
||||
});
|
||||
@@ -1,178 +0,0 @@
|
||||
// `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 };
|
||||
@@ -1,145 +0,0 @@
|
||||
'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);
|
||||
});
|
||||
@@ -1,315 +0,0 @@
|
||||
// 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,
|
||||
};
|
||||
@@ -1,202 +0,0 @@
|
||||
'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]);
|
||||
});
|
||||
@@ -1,110 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"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)"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
[
|
||||
{"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"}
|
||||
]
|
||||
@@ -1,108 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"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)"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"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
@@ -1,100 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"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)"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
[
|
||||
{"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"}
|
||||
]
|
||||
@@ -1,94 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"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)"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
[
|
||||
{"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"}
|
||||
]
|
||||
@@ -1,39 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,245 +0,0 @@
|
||||
#!/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()
|
||||
@@ -13,7 +13,6 @@ mod layers;
|
||||
mod licences;
|
||||
mod skill;
|
||||
mod test;
|
||||
mod unit_factors;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, ExitCode};
|
||||
@@ -54,8 +53,6 @@ commands:
|
||||
and the flags CI plans its jobs with: everything (manifest, lock file,
|
||||
toolchain, xtask or workflows changed), solver (the heavy tests or the oracle
|
||||
are affected), wasm (a crate the wasm check builds is affected)
|
||||
unit-factors report literal unit-conversion factors (1e-3, 1e6, 273.15, 25.4, …) written
|
||||
outside the unit table, crates/model/src/units (report only, #579)
|
||||
wasm cargo check every library crate and the web app for wasm32-unknown-unknown
|
||||
ci [--affected REV]
|
||||
fmt --check, assets, layers, clippy -D warnings, then the tests
|
||||
@@ -105,7 +102,6 @@ fn main() -> ExitCode {
|
||||
c.args(["run", "--release", "-p", affected::ORACLE_PACKAGE, "--bin", "oracle", "--"]).args(&rest);
|
||||
run(c, "oracle")
|
||||
}
|
||||
Some("unit-factors") => unit_factors::run(&root()),
|
||||
Some("wasm") => cmd_wasm(),
|
||||
Some("-h" | "--help" | "help") | None => {
|
||||
print!("{USAGE}");
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
//! `cargo xtask unit-factors`: report literal unit-conversion factors (1e-3, 1e6, 273.15, 25.4, …)
|
||||
//! written outside the unit table (`crates/model/src/units/`).
|
||||
//!
|
||||
//! Issue #579 routes every conversion through the table. Until the later steps have removed the
|
||||
//! existing factors this is a report and never fails; once the count is zero it becomes a CI check.
|
||||
//! Many hits are tolerances or algorithm constants rather than conversions: the report lists them
|
||||
//! all so each can be classified.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// The factors flagged, with what each usually converts.
|
||||
const FACTORS: &[(f64, &str)] = &[
|
||||
(1e-3, "milli (mm↔m, ms↔s, g↔kg)"),
|
||||
(1e3, "kilo (t↔kg, m↔mm)"),
|
||||
(1e6, "mega (MPa↔Pa, mm²↔m²)"),
|
||||
(1e9, "giga (GPa↔Pa, mm³↔m³)"),
|
||||
(1e-6, "micro (Pa↔MPa)"),
|
||||
(1e-9, "nano (Pa↔GPa, m³↔mm³)"),
|
||||
(1e12, "t/mm³↔kg/m³"),
|
||||
(1e-12, "kg/m³↔t/mm³"),
|
||||
(273.15, "°C↔K"),
|
||||
(459.67, "°F↔°R"),
|
||||
(25.4, "in↔mm"),
|
||||
(0.0254, "in↔m"),
|
||||
(9.80665, "standard gravity"),
|
||||
(9.81, "gravity"),
|
||||
(9810.0, "gravity in mm/s²"),
|
||||
];
|
||||
|
||||
/// The one place factors belong.
|
||||
const TABLE: &str = "crates/model/src/units/";
|
||||
const SKIP_DIRS: &[&str] = &["target", ".git", "plan", "dist", "xtask", "tests", "node_modules"];
|
||||
|
||||
pub fn run(root: &Path) -> Result<(), String> {
|
||||
let mut files = Vec::new();
|
||||
for dir in ["crates", "apps"] {
|
||||
walk(&root.join(dir), &mut files);
|
||||
}
|
||||
files.sort();
|
||||
let mut hits = Vec::new();
|
||||
for path in &files {
|
||||
let rel = path.strip_prefix(root).unwrap_or(path).to_string_lossy().replace('\\', "/");
|
||||
if rel.starts_with(TABLE) {
|
||||
continue;
|
||||
}
|
||||
let Ok(text) = std::fs::read_to_string(path) else { continue };
|
||||
for (n, line) in text.lines().enumerate() {
|
||||
// Unit tests sit at the end of a file behind `#[cfg(test)]`; their values are data.
|
||||
if line.trim_start().starts_with("#[cfg(test)]") {
|
||||
break;
|
||||
}
|
||||
let code = line.split("//").next().unwrap_or("");
|
||||
for value in literals(code) {
|
||||
if let Some((_, what)) = FACTORS.iter().find(|(f, _)| *f == value) {
|
||||
hits.push(format!("{rel}:{}: {value:e} ({what}): {}", n + 1, line.trim()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for h in &hits {
|
||||
println!("{h}");
|
||||
}
|
||||
println!("unit-factors: {} literal factor(s) outside {TABLE} (report only; #579 drives this to zero)", hits.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn walk(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return };
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
if p.is_dir() {
|
||||
if !SKIP_DIRS.contains(&name.as_str()) && !name.starts_with('.') {
|
||||
walk(&p, out);
|
||||
}
|
||||
} else if p.extension().is_some_and(|x| x == "rs") {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The floating-point literals on a line of code, parsed (`1_000.0`, `1e-3`,
|
||||
/// `273.15_f64`). Digits inside identifiers (`vec3`) are skipped.
|
||||
fn literals(code: &str) -> Vec<f64> {
|
||||
let chars: Vec<char> = code.chars().collect();
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while let Some(&c) = chars.get(i) {
|
||||
let prev_ident = i > 0 && chars.get(i - 1).is_some_and(|p| p.is_alphanumeric() || *p == '_' || *p == '.');
|
||||
if !c.is_ascii_digit() || prev_ident {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let start = i;
|
||||
while let Some(&d) = chars.get(i) {
|
||||
let exp_sign = matches!(d, '+' | '-') && matches!(chars.get(i.wrapping_sub(1)), Some('e' | 'E'));
|
||||
if d.is_ascii_digit() || d == '_' || d == '.' || d == 'e' || d == 'E' || exp_sign {
|
||||
// `0..n` is a range, `1.max(x)` a method call: stop at a dot not followed by a digit.
|
||||
if d == '.' && !chars.get(i + 1).is_some_and(char::is_ascii_digit) {
|
||||
break;
|
||||
}
|
||||
i += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let text: String = chars.get(start..i).unwrap_or_default().iter().filter(|c| **c != '_').collect();
|
||||
let suffix_start = i;
|
||||
// Skip a type suffix (`f64`, `_f32`, `u8`).
|
||||
while chars.get(i).is_some_and(|c| c.is_alphanumeric() || *c == '_') {
|
||||
i += 1;
|
||||
}
|
||||
let suffix: String = chars.get(suffix_start..i).unwrap_or_default().iter().collect();
|
||||
// Conversions are floating-point arithmetic; integer literals are counts and caps.
|
||||
let float = text.contains(['.', 'e', 'E']) || suffix.trim_start_matches('_').starts_with('f');
|
||||
if float && let Ok(v) = text.parse::<f64>() {
|
||||
out.push(v);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::literals;
|
||||
|
||||
#[test]
|
||||
fn literals_on_a_line() {
|
||||
assert_eq!(literals("let x = 1e-3 * y + 273.15_f64;"), vec![1e-3, 273.15]);
|
||||
assert_eq!(literals("for i in 0..1000 { vec3(1_000.0) }"), vec![1000.0]);
|
||||
assert_eq!(literals("a.max(1.0e6) - 2.max(b)"), vec![1e6]);
|
||||
assert_eq!(literals("t.0 + x1 + 25.4f32 + 3f64"), vec![25.4, 3.0]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user