diff --git a/AGENT_HANDOFF.md b/AGENT_HANDOFF.md
index 86b660833..313b9ac69 100644
--- a/AGENT_HANDOFF.md
+++ b/AGENT_HANDOFF.md
@@ -1,5 +1,40 @@
# AGENT_HANDOFF — Tools
+## 2026-08-11 local #4273 React request-workspace ownership
+
+The unpublished `codex/4273-ground-variation-file-ui` branch starts from exact
+PR #4366 head `8dfb1189c13f0fce99901e1ffbba152d813f9006`. Its read-only integration audit
+found that PyQt already has a main-window owner for both editors, while React
+kept the variation plan and regional draft/import evidence in mutually
+exclusive panel-local state. Switching tabs destroyed one side of the request,
+so binding File commands at that boundary would have been misleading.
+
+This bounded prerequisite adds one App-owned reducer and hook. The variation
+plan, analysis-execution selection, regional draft, and exact imported request
+now survive panel unmount/remount; both panels are controlled adapters. A typed
+request port composes only current state and transactionally applies a complete
+request after validating the two ground keys, global bounded noise, plan/base
+equality, identities, caps, and editor-qualified regional evidence. Invalid
+apply leaves every prior field unchanged, and the disclosed illustrative
+regional draft cannot be composed until explicitly edited or imported. The
+toolstrip receives the reserved
+typed port but exposes no new command in this slice.
+
+React now names the existing Python-owned ground restitution and rolling-
+resistance keys so applied plans remain inspectable. The scalar browser runner
+rejects those inputs visibly instead of routing them into unrelated flight
+physics. No physics, file dialog, upload/download, PyQt, browser-persistence,
+or filesystem semantics changed. TDD captured the missing owner and navigation
+reset. The complete React suite passes 763 tests across 121 files; TypeScript,
+ESLint, and the production Vite build pass. The campaign manifest and its eight
+tests, documentation governance, blocking-quality policy, module-size budget,
+and diff checks also pass. Vite retains its inherited main-chunk warning.
+
+Keep #4273/#4267 open. The next bounded child may add strict browser
+serialization and contextual File controls plus the PyQt native controller,
+using this owner rather than an imperative registry or illustrative fallback.
+No push or GitHub write occurred from this branch.
+
## 2026-08-11 local #4273 seeded-request persistence
The unpublished `codex/4273-ground-variation-persistence` branch starts from
diff --git a/SPEC.md b/SPEC.md
index 62c1da886..9c649b9ba 100644
--- a/SPEC.md
+++ b/SPEC.md
@@ -26,8 +26,8 @@
| **Owner** | D-sorganization |
| **Primary Language(s)** | Python 3.11+, Rust, JavaScript, TypeScript |
| **License** | MIT |
-| **Current Version** | 1.14.57 |
-| **Spec Version** | 1.14.57 |
+| **Current Version** | 1.14.58 |
+| **Spec Version** | 1.14.58 |
| **Last Spec Update** | 2026-08-11 |
## 2. Purpose & Mission
@@ -46,6 +46,29 @@ Comprehensive monorepo housing 45+ utility tools for data processing, scientific
## 3. Goals & Non-Goals
+### 2026-08-11 React Regional-Ground Variation Workspace Ownership
+
+- One App-owned reducer now retains the complete physical variation-plan
+ editor, analysis-execution choice, regional-surface draft, and exact imported
+ regional request while the mutually exclusive workspace panels unmount.
+ `VariationPanel` and `RegionalSurfacePlanPanel` are controlled adapters; no
+ module-local fallback can silently replace the user's current request state.
+- A typed request port composes only the current editors and validates launch
+ mode, the two supported base-material keys, global explicit `[0, 1]` bounds,
+ base-plan equality, identifiers, row caps, and editor-qualified regional
+ evidence. Whole-request apply validates and derives every replacement field
+ before dispatch, so invalid input cannot partially mutate the workspace.
+ The disclosed illustrative regional draft is never eligible for composition
+ until the user explicitly edits it or imports qualified evidence.
+- The web registry names normal restitution and rolling resistance so an
+ applied request remains inspectable. The existing scalar browser runner
+ rejects those inputs visibly because the regional-ground pipeline has not
+ been integrated there; this slice changes no physics.
+- This session-state prerequisite excludes File-menu controls, upload/download
+ serialization, native dialogs, PyQt wiring, browser filesystem claims,
+ execution-pipeline integration, protected publication, and #4273/#4267
+ completion.
+
### 2026-08-11 Seeded Regional-Ground Request Persistence
- A strict v1 application envelope stores the existing immutable seeded
@@ -3153,6 +3176,8 @@ Active development with stable core, continuous tool expansion, and web API in p
| Date | Version | Changes |
| ---- | ------- | ------- |
+| 2026-08-11 | 1.14.58 | feat(ground-study, #4273): lift the React variation plan, execution policy, regional draft, and exact imported evidence into one App-owned transactional workspace; make both panels controlled across navigation; expose a typed compose/apply request port; register the two inspectable ground inputs while failing closed before unsupported scalar-browser execution; retain File controls, PyQt wiring, browser persistence, pipeline integration, protected release, and issue/epic completion as open. |
+| 2026-08-11 | 1.14.57 | feat(ground-study, #4273): add strict canonical bounded seeded regional-ground request persistence without UI or physics. |
| 2026-08-11 | 1.14.56 | docs(ground-study, #4365): record ready publication of the seeded base-material variation runner at implementation `8c9c9512c61bac6f958ae7c7c0fe58e8f70525bf`, stacked on exact #4364 head `f13f0908dd2a553cf4d114afd31bb474d1b967c7`; retain protected checks, UI/overlay/persistence, solver/wind, compiled/downstream parity, release, and #4273/#4267 completion as open. |
| 2026-08-11 | 1.14.55 | feat(ground-study, #4273 #4267): add deterministic VariationPlan-driven base normal-restitution and rolling-resistance studies with immutable per-trial provenance, exact injected flight-through-regional-ground execution, bounded sampled-input scalar ensembles, typed-null unavailable rows, and fail-before-execution validation; retain region overlays, UI/persistence, solver/wind integration, compiled/downstream parity, protected release, and issue/epic completion as open. |
| 2026-08-11 | 1.14.54 | feat(ground-target, #4192 #4273 #4267): add a UI-neutral complete-rest-only projection from exact flight-through-regional-ground evidence to the existing `SpatialTarget`; record the sole `GroundFrame.TARGET`, retain final x/z, replace ball-center y exactly once with declared landing-surface elevation, reuse `SpatialTarget.miss` signs and geometry, type aerial targets as `AERIAL_REQUIRES_FLIGHT_TRAJECTORY`, preserve every unqualified/censored outcome as null with exact phase/reason/digest evidence, and expose a bounded ordered target scalar ensemble while leaving UI, persistence, solver/capability, aerial-trajectory, compiled-runtime, protected-release, and issue completion open. |
diff --git a/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md b/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md
index 3659d6636..64e9bacae 100644
--- a/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md
+++ b/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md
@@ -1,5 +1,30 @@
# Rate of Closure Ball-Flight Campaign Handoff
+## 2026-08-11 local #4273 React request-workspace ownership
+
+- The unpublished `codex/4273-ground-variation-file-ui` child starts from exact
+ PR #4366 head `8dfb1189c13f0fce99901e1ffbba152d813f9006`. Audit showed a clean PyQt
+ main-window composition seam but no React owner above the mutually exclusive
+ Variation and Ground Surfaces panels; navigation discarded one editor.
+- A UI-neutral reducer and App-owned hook now retain the physical variation
+ plan, analysis policy, regional draft, and exact imported request across
+ navigation. Both panels are controlled. A reserved typed toolstrip port
+ snapshots only current state and validates a complete replacement before one
+ atomic reducer transition, so invalid requests cannot partially apply. The
+ disclosed illustrative draft remains ineligible until edited or imported.
+- The two existing Rate ground input keys are inspectable in the web registry.
+ Browser scalar execution fails closed with a visible unsupported-path status;
+ no regional physics was reimplemented or approximated.
+- RED captured the missing owner and navigation reset. All 763 React tests in
+ 121 files pass, as do TypeScript, ESLint, the production Vite build, campaign
+ manifest validation plus eight tests, docs governance, blocking-quality
+ policy, the scoped 400-line module budget, and diff checks. Vite reports only
+ its inherited main-chunk size warning.
+- This local slice adds no File controls, request upload/download adapter,
+ native dialog, PyQt wiring, persistent browser workspace, or protected
+ evidence. Keep #4273/#4267 open and stack those remaining clients only on
+ this state owner. No branch was pushed and no GitHub state changed.
+
## 2026-08-11 local #4273 seeded-request persistence
- The unpublished `codex/4273-ground-variation-persistence` branch starts from
diff --git a/docs/release/rate_of_closure_campaign.v1.json b/docs/release/rate_of_closure_campaign.v1.json
index b0f942846..05ae2d96d 100644
--- a/docs/release/rate_of_closure_campaign.v1.json
+++ b/docs/release/rate_of_closure_campaign.v1.json
@@ -1394,6 +1394,18 @@
"kind": "repository_path",
"value": "src/rate_of_closure/application/regional_ground_variation_request.py"
},
+ {
+ "kind": "repository_path",
+ "value": "src/rate_of_closure/web/src/model/regionalGroundVariationWorkspace.ts"
+ },
+ {
+ "kind": "repository_path",
+ "value": "src/rate_of_closure/web/src/hooks/useRegionalGroundVariationWorkspace.ts"
+ },
+ {
+ "kind": "repository_path",
+ "value": "src/rate_of_closure/web/src/App.workspaceState.test.tsx"
+ },
{
"kind": "repository_path",
"value": "src/rate_of_closure/simulation/ground_playback.py"
@@ -1447,12 +1459,12 @@
"ground-regional-ui-helper-local-d217",
"ground-regional-io-local-8e1c"
],
- "evidence_gap": "The contract, flight-transfer, impact/bounce, static-plane skid/roll, regional-surface, regional-plan wire, regional-editor, regional request-I/O, regional execution, evidence-readback, complete-result, ledger-inspection, trajectory-export, repeated-bounce result-wire, repeated-bounce request-wire, UI-neutral repeated-bounce execution, flight-to-bounce composition, full flight-through-regional-ground composition, qualified study-adapter, matched ground-playback, post-ground target, and seeded base-material variation carriers remain open on PRs 4285, 4288, 4302, 4304, 4332, 4335, 4339, 4342, 4350, 4351, 4352, 4353, 4354, 4355, 4356, 4357, 4359, 4360, 4361, 4363, 4364, and 4365. PR 4361 adds a UI-neutral complete-rest-only adapter into existing flight metrics and scalar ensembles while retaining partial, censored, cancelled, failed, non-settled, missing-summary, and transfer-failure outcomes as typed nullable rows. PR 4363 adds matched import-only PyQt6 and React playback with phase-safe absolute time, bounded presentation, and no physics. PR 4364 maps only qualified final x/z through the sole target frame, substitutes declared surface elevation once, reuses SpatialTarget miss geometry, and emits bounded typed target rows. PR 4365 varies only base normal restitution and rolling resistance with deterministic plan-bound provenance and qualified nullable scalar outputs. An unpublished exact-#4365 local child adds strict canonical 1 MiB seeded-request JSON plus bounded native atomic persistence, but has no UI, browser file operation, carrier, or protected evidence. Fresh protected evidence, regional-overlay variation, solver/capability invocation, variation UI, UI pipeline invocation, direct editor handoff, workspace embedding, measured calibration, changing geometry, rendered visual QA, camera presets/tracking, compiled-runtime physics, and downstream parity remain absent.",
+ "evidence_gap": "The contract, flight-transfer, impact/bounce, static-plane skid/roll, regional-surface, regional-plan wire, regional-editor, regional request-I/O, regional execution, evidence-readback, complete-result, ledger-inspection, trajectory-export, repeated-bounce result-wire, repeated-bounce request-wire, UI-neutral repeated-bounce execution, flight-to-bounce composition, full flight-through-regional-ground composition, qualified study-adapter, matched ground-playback, post-ground target, and seeded base-material variation carriers remain open on PRs 4285, 4288, 4302, 4304, 4332, 4335, 4339, 4342, 4350, 4351, 4352, 4353, 4354, 4355, 4356, 4357, 4359, 4360, 4361, 4363, 4364, and 4365. PR 4361 adds a UI-neutral complete-rest-only adapter into existing flight metrics and scalar ensembles while retaining partial, censored, cancelled, failed, non-settled, missing-summary, and transfer-failure outcomes as typed nullable rows. PR 4363 adds matched import-only PyQt6 and React playback with phase-safe absolute time, bounded presentation, and no physics. PR 4364 maps only qualified final x/z through the sole target frame, substitutes declared surface elevation once, reuses SpatialTarget miss geometry, and emits bounded typed target rows. PR 4365 varies only base normal restitution and rolling resistance with deterministic plan-bound provenance and qualified nullable scalar outputs. One unpublished exact-#4365 local child adds strict canonical 1 MiB seeded-request JSON plus bounded native atomic persistence. Its exact-#4366 child now adds React-only App-owned variation/regional editor state, exact imported-evidence retention, transactional compose/apply validation, and an unused typed File-command seam. It adds no File controls, browser serialization, PyQt wiring, physics execution, carrier, or protected evidence. Fresh protected evidence, regional-overlay variation, solver/capability invocation, UI pipeline invocation, direct editor handoff, measured calibration, changing geometry, rendered visual QA, camera presets/tracking, compiled-runtime physics, and downstream parity remain absent.",
"limitations": [
"Airborne flight must first terminate against physical terrain and preserve terminal angular velocity.",
"Current UpstreamDrift terrain and putting implementations are reference inputs rather than the qualified authority.",
"The #4270 rigid impact law does not consume firmness, grass, compressibility, moisture, or rolling-resistance fields.",
- "Published open PR #4365 supports seeded variation only on the base surface; an unpublished child adds canonical request persistence only, while regional overlays, UI, workspace embedding, browser file operations, and solver/capability consumption remain absent.",
+ "Published open PR #4365 supports seeded variation only on the base surface; unpublished children add canonical request persistence and React session-state ownership only. File controls, browser upload/download serialization, PyQt wiring, regional overlays, UI pipeline execution, and solver/capability consumption remain absent.",
"Published open PR #4304 qualifies one immutable planar profile and optional finite tangent-axis edge; open PR #4332 adds bounded coplanar material overlays; open PR #4335 adds a strict Python/TypeScript plan wire boundary and Python resolver; open PR #4339 adds matched PyQt6/React request editors; open PR #4342 adds editor-qualified canonical request import/export with native atomic semantics and explicit browser limitations; open PR #4350 adds UI-neutral Python regional execution plus an embedded-plan-bound result/provenance envelope with validated transition crossings; open PR #4351 adds matched strict plan-bound evidence import/readback; open PR #4352 completes all frozen summary/result fields, warnings, calibration, provider identity, and observed-phase readback; open PR #4353 adds bounded ground-event and regional-transition ledger inspection; open PR #4354 adds bounded raw trajectory inspection plus native-atomic and browser-download canonical evidence export; open PR #4355 adds strict import-only Python/TypeScript RepeatedBounceResult evidence serialization and parsing with exact nested invariants, a 1 MiB bound, and canonical SHA parity; open PR #4356 adds the strict paired request envelope; open PR #4357 adds the UI-neutral Python request-to-solver binding; open PR #4359 adds strict composition from qualified flight output through the existing transfer and request boundaries to that executor; open PR #4360 composes the existing flight, bounce, regional skid/roll, and complete-result authorities behind one strict non-fabricating evidence contract; open PR #4361 adds a complete-rest-only Python adapter into existing metric and scalar-ensemble contracts without promoting censored numeric endpoints; open PR #4363 adds strict phase-safe import-only playback to matched PyQt6/React workspaces; and open PR #4364 evaluates only qualified landing targets while retaining typed nulls for aerial and every unqualified outcome. Changing normals, height or velocity discontinuities, terrain deformation, torsional-spin damping, roll-to-skid transitions, direct editor handoff, persistence, aerial target trajectory evaluation, measured calibration, workspace model-input persistence, rendered visual QA, camera presets/tracking, compiled or TypeScript regional physics, solver/capability invocation, variation UI, wind strategy, and downstream surface adapters remain unimplemented."
],
"depends_on_issues": [
diff --git a/src/rate_of_closure/AGENT_HANDOFF.md b/src/rate_of_closure/AGENT_HANDOFF.md
index 3c2f8b23e..8e19ebd3c 100644
--- a/src/rate_of_closure/AGENT_HANDOFF.md
+++ b/src/rate_of_closure/AGENT_HANDOFF.md
@@ -1,5 +1,34 @@
# AGENT_HANDOFF — rate_of_closure
+## 2026-08-11 local #4273 React request-workspace ownership
+
+The unpublished `codex/4273-ground-variation-file-ui` child starts from exact
+PR #4366 head `8dfb1189c13f0fce99901e1ffbba152d813f9006`. React previously owned the
+variation and regional-surface inputs inside separate panels that unmount on
+navigation. The new App-owned reducer/hook retains the complete request-editor
+state and exact imported regional evidence; both panels are controlled.
+
+`RegionalGroundVariationRequestTs` and its typed port provide a future
+File-command seam. Snapshot uses only the current plan and current regional
+draft/import evidence. Complete apply validates all fields and derives the
+editor draft before dispatch, so invalid input cannot partially replace state.
+The disclosed illustrative regional draft is not composable until the user
+explicitly edits it or imports qualified evidence.
+The web registry exposes the two existing ground-material keys, while the
+scalar browser runner rejects them with a visible unsupported-path message.
+No physics or persistence behavior is added.
+
+RED captured the missing owner and tab-reset behavior. The full React suite is
+green at 763 tests in 121 files. TypeScript, ESLint, Vite production build,
+campaign manifest validation and its eight tests, documentation governance,
+blocking-quality policy, scoped module-size, and diff gates pass; the Vite
+main-chunk warning is inherited.
+
+File controls, strict combined-schema TypeScript serialization, browser
+upload/download, PyQt native actions, pipeline execution, protected review,
+publication, and downstream parity remain open. Keep #4273/#4267 open. No push
+or GitHub write occurred.
+
## 2026-08-11 local #4273 seeded-request persistence
The unpublished `codex/4273-ground-variation-persistence` child starts from
diff --git a/src/rate_of_closure/web/src/App.tsx b/src/rate_of_closure/web/src/App.tsx
index 205fdfa3d..07cefc44d 100644
--- a/src/rate_of_closure/web/src/App.tsx
+++ b/src/rate_of_closure/web/src/App.tsx
@@ -5,6 +5,7 @@ import { PrimaryViewTabs } from "./components/PrimaryViewTabs";
import { PrimaryWorkspacePanel } from "./components/PrimaryWorkspacePanel";
import { useAppWorkspace } from "./hooks/useAppWorkspace";
import { useImpactAppModel } from "./hooks/useImpactAppModel";
+import { useRegionalGroundVariationWorkspace } from "./hooks/useRegionalGroundVariationWorkspace";
import { HELP_TEXTS } from "./model/helptext";
import { primaryViewLabel, type PrimaryViewId } from "./model/viewPreferences";
@@ -61,6 +62,7 @@ function AppFooter() {
export default function App() {
const workspace = useAppWorkspace();
const model = useImpactAppModel();
+ const regionalGroundVariation = useRegionalGroundVariationWorkspace();
const active = workspace.viewState.active;
const openGlossary = (term: string | undefined) => {
model.setGlossaryTerm(term);
@@ -73,6 +75,7 @@ export default function App() {
shortcutHelpOpen={workspace.shortcutHelpOpen}
onModuleStateChange={workspace.setViewState}
onCommand={workspace.handleCommand}
+ regionalGroundVariationRequestPort={regionalGroundVariation.requestPort}
onShortcutHelpOpenChange={workspace.setShortcutHelpOpen} />
diff --git a/src/rate_of_closure/web/src/App.workspaceState.test.tsx b/src/rate_of_closure/web/src/App.workspaceState.test.tsx
new file mode 100644
index 000000000..a2502f586
--- /dev/null
+++ b/src/rate_of_closure/web/src/App.workspaceState.test.tsx
@@ -0,0 +1,70 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import App from "./App";
+import { stableGroundRegionalMaterialPlanJson } from "./model/groundRegionalPlan";
+import {
+ buildGroundRegionalSurfacePlanRequest,
+ illustrativeRegionalSurfacePlanDraft,
+} from "./model/regionalSurfacePlan";
+
+describe("App regional-ground variation workspace ownership", () => {
+ beforeEach(() => {
+ vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
+ });
+
+ afterEach(() => vi.restoreAllMocks());
+
+ it("preserves both controlled editors across mutually exclusive navigation", () => {
+ render();
+
+ fireEvent.click(screen.getByRole("tab", { name: "Variation" }));
+ const runs = screen.getByRole("textbox", { name: "Runs" });
+ fireEvent.focus(runs);
+ fireEvent.change(runs, { target: { value: "37" } });
+ fireEvent.blur(runs);
+ fireEvent.change(screen.getByRole("combobox", { name: "Analysis execution" }), {
+ target: { value: "individual" },
+ });
+
+ fireEvent.click(screen.getByRole("tab", { name: "Ground Surfaces" }));
+ fireEvent.change(screen.getByLabelText("Regional plan request ID"), {
+ target: { value: "edited-between-tabs" },
+ });
+
+ fireEvent.click(screen.getByRole("tab", { name: "Variation" }));
+ expect(screen.getByRole("textbox", { name: "Runs" })).toHaveValue("37");
+ expect(screen.getByRole("combobox", { name: "Analysis execution" }))
+ .toHaveValue("individual");
+
+ fireEvent.click(screen.getByRole("tab", { name: "Ground Surfaces" }));
+ expect(screen.getByLabelText("Regional plan request ID"))
+ .toHaveValue("edited-between-tabs");
+ });
+
+ it("retains imported regional evidence after navigating away", async () => {
+ render();
+ fireEvent.click(screen.getByRole("tab", { name: "Ground Surfaces" }));
+ const request = buildGroundRegionalSurfacePlanRequest({
+ ...illustrativeRegionalSurfacePlanDraft(),
+ request_id: "import-survives-navigation",
+ });
+ const text = stableGroundRegionalMaterialPlanJson(request);
+ const file = {
+ name: "regional.json",
+ size: new TextEncoder().encode(text).byteLength,
+ text: vi.fn().mockResolvedValue(text),
+ };
+ fireEvent.change(screen.getByLabelText("Import regional surface plan JSON file"), {
+ target: { files: [file] },
+ });
+ await waitFor(() => expect(screen.getByLabelText("Regional plan request ID"))
+ .toHaveValue("import-survives-navigation"));
+
+ fireEvent.click(screen.getByRole("tab", { name: "Variation" }));
+ fireEvent.click(screen.getByRole("tab", { name: "Ground Surfaces" }));
+
+ expect(screen.getByLabelText("Regional plan request ID"))
+ .toHaveValue("import-survives-navigation");
+ });
+});
diff --git a/src/rate_of_closure/web/src/components/AppToolstrip.tsx b/src/rate_of_closure/web/src/components/AppToolstrip.tsx
index c69b13676..7eac340db 100644
--- a/src/rate_of_closure/web/src/components/AppToolstrip.tsx
+++ b/src/rate_of_closure/web/src/components/AppToolstrip.tsx
@@ -6,6 +6,7 @@ import {
type AppCommandId,
} from "../model/appCommands";
import type { AppTheme } from "../model/appTheme";
+import type { RegionalGroundVariationRequestPort } from "../model/regionalGroundVariationWorkspace";
import {
PRIMARY_VIEWS,
restorePrimaryViewDefaults,
@@ -21,6 +22,8 @@ interface AppToolstripProps {
readonly onModuleStateChange: (state: PrimaryViewState) => void;
readonly onCommand: (command: AppCommandId) => void;
readonly onShortcutHelpOpenChange: (open: boolean) => void;
+ /** Reserved typed seam for the subsequent contextual File-command slice. */
+ readonly regionalGroundVariationRequestPort?: RegionalGroundVariationRequestPort;
}
const MENU_CLASS =
diff --git a/src/rate_of_closure/web/src/components/PrimaryWorkspacePanel.tsx b/src/rate_of_closure/web/src/components/PrimaryWorkspacePanel.tsx
index fd52c0478..95d4c9fbb 100644
--- a/src/rate_of_closure/web/src/components/PrimaryWorkspacePanel.tsx
+++ b/src/rate_of_closure/web/src/components/PrimaryWorkspacePanel.tsx
@@ -1,6 +1,7 @@
import { lazy, Suspense } from "react";
import type { ImpactAppModel } from "../hooks/useImpactAppModel";
+import type { RegionalGroundVariationWorkspaceController } from "../hooks/useRegionalGroundVariationWorkspace";
import type { PrimaryViewId } from "../model/viewPreferences";
import { Derivation } from "./Derivation";
import { FlightExplorerPanel } from "./FlightExplorerPanel";
@@ -27,6 +28,7 @@ const LazyGroundPlaybackPanel = lazy(() =>
interface WorkspacePanelProps {
readonly active: PrimaryViewId;
readonly model: ImpactAppModel;
+ readonly regionalGroundVariation: RegionalGroundVariationWorkspaceController;
readonly onOpenGlossary: (term: string | undefined) => void;
}
@@ -41,7 +43,9 @@ function SimulationWorkspace({ model }: { readonly model: ImpactAppModel }) {
);
}
-function ExplorerWorkspace(props: Omit) {
+function ExplorerWorkspace(
+ props: Pick,
+) {
const { model } = props;
return (
) {
}
export function PrimaryWorkspacePanel(props: WorkspacePanelProps) {
- const { active, model, onOpenGlossary } = props;
+ const { active, model, onOpenGlossary, regionalGroundVariation } = props;
switch (active) {
case "glossary":
return ;
case "putting":
return ;
case "variation":
- return ;
+ return ;
case "flight":
return ;
case "regional-surfaces":
- return ;
+ return ;
case "ground-playback":
return (
Loading…}>
diff --git a/src/rate_of_closure/web/src/components/RegionalSurfacePlanPanel.test.tsx b/src/rate_of_closure/web/src/components/RegionalSurfacePlanPanel.test.tsx
index 72d77dc51..c85858179 100644
--- a/src/rate_of_closure/web/src/components/RegionalSurfacePlanPanel.test.tsx
+++ b/src/rate_of_closure/web/src/components/RegionalSurfacePlanPanel.test.tsx
@@ -1,5 +1,6 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
+import { useState } from "react";
import {
MAX_REGIONAL_SURFACE_EDITOR_ROWS,
@@ -15,6 +16,17 @@ import {
} from "../model/groundRegionalPlan";
import { RegionalSurfacePlanPanel } from "./RegionalSurfacePlanPanel";
+function TestRegionalSurfacePlanPanel() {
+ const [draft, setDraft] = useState(illustrativeRegionalSurfacePlanDraft);
+ const [importedRequest, setImportedRequest] =
+ useState | null>(null);
+ return {
+ setDraft(editorDraftFromGroundRegionalSurfacePlanRequest(request));
+ setImportedRequest(request);
+ }} />;
+}
+
afterEach(() => vi.restoreAllMocks());
describe("RegionalSurfacePlanPanel", () => {
@@ -28,7 +40,7 @@ describe("RegionalSurfacePlanPanel", () => {
});
it("shows explicit qualification, SI units, and strict validated readback", () => {
- render();
+ render();
expect(screen.getByRole("note", { name: "Regional surface qualification" }))
.toHaveTextContent(/illustrative.*unvalidated/i);
@@ -46,7 +58,7 @@ describe("RegionalSurfacePlanPanel", () => {
});
it("invalidates a validated readback as soon as the draft changes", () => {
- render();
+ render();
fireEvent.click(screen.getByRole("button", { name: "Validate surface plan" }));
expect(screen.getByRole("status", { name: "Regional surface plan readback" }))
@@ -59,7 +71,7 @@ describe("RegionalSurfacePlanPanel", () => {
});
it("accepts the exact cross-runtime maximum precedence", () => {
- render();
+ render();
fireEvent.change(screen.getByLabelText("Overlay 1 precedence"), {
target: { value: String(Number.MAX_SAFE_INTEGER) },
});
@@ -72,7 +84,7 @@ describe("RegionalSurfacePlanPanel", () => {
});
it("rejects integer-valued material numbers beyond the cross-runtime safe range", () => {
- render();
+ render();
fireEvent.change(screen.getByLabelText("Base Firmness (Pa)"), {
target: { value: "10000000000000000" },
});
@@ -85,7 +97,7 @@ describe("RegionalSurfacePlanPanel", () => {
});
it("links invalid interval fields to an accessible error without clearing input", () => {
- render();
+ render();
const lower = screen.getByLabelText("Overlay 1 lower coordinate (m)");
fireEvent.change(lower, { target: { value: "160" } });
@@ -98,7 +110,7 @@ describe("RegionalSurfacePlanPanel", () => {
});
it("keeps keyboard focus while a stable region identity is edited", () => {
- render();
+ render();
const identity = screen.getByLabelText("Overlay 1 region ID");
identity.focus();
@@ -109,7 +121,7 @@ describe("RegionalSurfacePlanPanel", () => {
});
it("bounds overlay rows and never advertises execution or persistence", () => {
- render();
+ render();
const add = screen.getByRole("button", { name: "Add regional overlay" });
for (let index = 1; index < MAX_REGIONAL_SURFACE_EDITOR_ROWS; index += 1) {
fireEvent.click(add);
@@ -142,7 +154,7 @@ describe("RegionalSurfacePlanPanel", () => {
});
it("imports only after complete validation and retains the prior draft on error", async () => {
- render();
+ render();
const request = buildGroundRegionalSurfacePlanRequest({
...illustrativeRegionalSurfacePlanDraft(), request_id: "browser-opened-plan",
});
@@ -178,7 +190,7 @@ describe("RegionalSurfacePlanPanel", () => {
});
it("rejects non-editor qualification without populating the editor", async () => {
- render();
+ render();
const request = buildGroundRegionalSurfacePlanRequest(
illustrativeRegionalSurfacePlanDraft(),
);
@@ -198,7 +210,7 @@ describe("RegionalSurfacePlanPanel", () => {
});
it("rejects oversized browser files before allocating their text", async () => {
- render();
+ render();
const input = screen.getByLabelText("Import regional surface plan JSON file");
const text = vi.fn().mockResolvedValue("{}");
const file = {
@@ -219,7 +231,7 @@ describe("RegionalSurfacePlanPanel", () => {
Object.defineProperty(URL, "createObjectURL", { configurable: true, value: createUrl });
Object.defineProperty(URL, "revokeObjectURL", { configurable: true, value: revokeUrl });
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
- render();
+ render();
fireEvent.click(screen.getByRole("button", { name: "Download regional surface plan JSON" }));
@@ -229,7 +241,7 @@ describe("RegionalSurfacePlanPanel", () => {
});
it("treats an empty browser chooser as cancellation", () => {
- render();
+ render();
const input = screen.getByLabelText("Import regional surface plan JSON file");
fireEvent.change(input, { target: { files: [] } });
diff --git a/src/rate_of_closure/web/src/components/RegionalSurfacePlanPanel.tsx b/src/rate_of_closure/web/src/components/RegionalSurfacePlanPanel.tsx
index 0f5e79f1e..95db6cf9f 100644
--- a/src/rate_of_closure/web/src/components/RegionalSurfacePlanPanel.tsx
+++ b/src/rate_of_closure/web/src/components/RegionalSurfacePlanPanel.tsx
@@ -138,10 +138,19 @@ function nextOverlay(draft: RegionalSurfacePlanDraft): RegionalOverlayDraft {
};
}
-export function RegionalSurfacePlanPanel() {
- const [draft, setDraft] = useState(illustrativeRegionalSurfacePlanDraft);
- const [importedRequest, setImportedRequest] =
- useState(null);
+export interface RegionalSurfacePlanPanelProps {
+ readonly draft: RegionalSurfacePlanDraft;
+ readonly importedRequest: GroundRegionalMaterialPlanRequest | null;
+ readonly onDraftChange: (draft: RegionalSurfacePlanDraft) => void;
+ readonly onImport: (request: GroundRegionalMaterialPlanRequest) => void;
+}
+
+export function RegionalSurfacePlanPanel({
+ draft,
+ importedRequest,
+ onDraftChange,
+ onImport,
+}: RegionalSurfacePlanPanelProps) {
const [error, setError] = useState(null);
const [readback, setReadback] = useState(null);
const [fileStatus, setFileStatus] = useState(null);
@@ -151,7 +160,7 @@ export function RegionalSurfacePlanPanel() {
const updateDraft = (
transform: (current: RegionalSurfacePlanDraft) => RegionalSurfacePlanDraft,
) => {
- setDraft(transform);
+ onDraftChange(transform(draft));
setError(null);
setReadback(null);
setFileStatus(null);
@@ -185,9 +194,8 @@ export function RegionalSurfacePlanPanel() {
if (file === undefined) return;
try {
const request = await readRegionalSurfacePlanFile(file);
- const importedDraft = editorDraftFromGroundRegionalSurfacePlanRequest(request);
- setDraft(importedDraft);
- setImportedRequest(request);
+ editorDraftFromGroundRegionalSurfacePlanRequest(request);
+ onImport(request);
setValidationAttempted(false);
setError(null);
setReadback(`${request.schema_version} · exact imported provenance ${request.provenance.input_sha256}`);
diff --git a/src/rate_of_closure/web/src/components/VariationPanel.test.tsx b/src/rate_of_closure/web/src/components/VariationPanel.test.tsx
index 40579caa0..2d44f898b 100644
--- a/src/rate_of_closure/web/src/components/VariationPanel.test.tsx
+++ b/src/rate_of_closure/web/src/components/VariationPanel.test.tsx
@@ -1,11 +1,22 @@
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { useState } from "react";
-import { CATEGORY_LAUNCH, planFromJson, planToJson, type VariationPlanTs } from "../model/variation";
+import {
+ CATEGORY_LAUNCH,
+ GROUND_NORMAL_RESTITUTION_KEY,
+ GROUND_ROLLING_RESISTANCE_KEY,
+ planFromJson,
+ planToJson,
+ type VariationPlanTs,
+} from "../model/variation";
import { VARIATION_PLAN_LIBRARY_KEY } from "../model/variationPlanLibrary";
-import { VariationPanel } from "./VariationPanel";
-import { saveBallSetupPreference } from "../model/ballSetupPersistence";
+import { VariationPanel, type VariationPanelProps } from "./VariationPanel";
+import { loadBallSetupPreference, saveBallSetupPreference } from "../model/ballSetupPersistence";
+import { DRIVER_TEE_HEIGHT_M } from "../model/ballSetup";
+import { defaultVariationPlan } from "../model/variationDefaults";
+import type { VariationAnalysisExecution } from "../model/variationAnalysisPolicy";
import {
createSpatialTarget,
sphereTolerance,
@@ -68,6 +79,25 @@ const importedPlan = (): VariationPlanTs => ({
let storage: Storage;
+type TestPanelProps = Omit<
+ VariationPanelProps,
+ "plan" | "analysisExecution" | "onPlanChange" | "onAnalysisExecutionChange"
+>;
+
+function TestVariationPanel(props: TestPanelProps) {
+ const [plan, setPlan] = useState(() => ({
+ ...defaultVariationPlan(),
+ ballSetup: loadBallSetupPreference(
+ props.storage,
+ { supportMode: "tee" as const, teeHeightM: DRIVER_TEE_HEIGHT_M },
+ ).setup,
+ }));
+ const [analysisExecution, setAnalysisExecution] =
+ useState("both");
+ return ;
+}
+
beforeEach(() => {
storage = new MemoryStorage();
Object.defineProperty(URL, "createObjectURL", {
@@ -96,7 +126,7 @@ describe("VariationPanel v2 plan persistence", () => {
tolerance: sphereTolerance(4),
elevationSource: "absolute",
});
- render();
+ render();
const summary = screen.getByRole("status", {
name: "Variation current spatial target",
});
@@ -105,7 +135,7 @@ describe("VariationPanel v2 plan persistence", () => {
});
it("presents a complete results workspace before the first run", () => {
- render();
+ render();
expect(screen.getByRole("region", { name: "Variation results" })).toHaveClass("min-w-0");
expect(screen.getByRole("heading", { name: "Ready to Analyze Variation" })).toBeVisible();
@@ -121,7 +151,7 @@ describe("VariationPanel v2 plan persistence", () => {
setup: { supportMode: "ground", teeHeightM: 0 },
userOverridden: true,
}, storage);
- const ground = render();
+ const ground = render();
expect(screen.getByText(/Tee Height is excluded in Ground mode/i)).toBeInTheDocument();
expect(within(screen.getByLabelText("Variable 1")).queryByRole("option", {
name: "Tee Height",
@@ -132,7 +162,7 @@ describe("VariationPanel v2 plan persistence", () => {
setup: { supportMode: "tee", teeHeightM: 0.0381 },
userOverridden: true,
}, storage);
- render();
+ render();
expect(screen.getByText(/Tee Height is available.*active Tee setup/i)).toBeInTheDocument();
expect(within(screen.getByLabelText("Variable 1")).getByRole("option", {
name: "Tee Height",
@@ -141,7 +171,7 @@ describe("VariationPanel v2 plan persistence", () => {
it("retains the complete imported plan when saving it to the named library", async () => {
const user = userEvent.setup();
- render();
+ render();
const file = new File([planToJson(importedPlan())], "plan.json", {
type: "application/json",
});
@@ -164,7 +194,7 @@ describe("VariationPanel v2 plan persistence", () => {
it("supports loading, duplicating, and deleting named plans", async () => {
const user = userEvent.setup();
- render();
+ render();
await user.type(screen.getByRole("textbox", { name: "Plan name" }), "Baseline");
await user.click(screen.getByRole("button", { name: "Save Named Plan" }));
expect(screen.getByRole("combobox", { name: "Saved plan library" })).toHaveTextContent(
@@ -186,7 +216,7 @@ describe("VariationPanel v2 plan persistence", () => {
it("reports corrupt library recovery without preventing a new save", async () => {
storage.setItem(VARIATION_PLAN_LIBRARY_KEY, "{broken");
const user = userEvent.setup();
- render();
+ render();
expect(screen.getByRole("status")).toHaveTextContent(/corrupt/i);
await user.type(screen.getByRole("textbox", { name: "Plan name" }), "Recovered");
await user.click(screen.getByRole("button", { name: "Save Named Plan" }));
@@ -195,9 +225,43 @@ describe("VariationPanel v2 plan persistence", () => {
});
describe("VariationPanel analysis execution policy", () => {
+ it("fails closed instead of sending ground inputs to scalar flight physics", async () => {
+ const user = userEvent.setup();
+ render();
+ const plan: VariationPlanTs = {
+ mode: "launch",
+ baseVariables: {
+ [GROUND_NORMAL_RESTITUTION_KEY]: 0.42,
+ [GROUND_ROLLING_RESISTANCE_KEY]: 0.04,
+ },
+ noise: [{
+ variableKey: GROUND_NORMAL_RESTITUTION_KEY,
+ distribution: "normal",
+ scale: 0.02,
+ lower: 0.1,
+ upper: 0.8,
+ }],
+ nRuns: 2,
+ seed: 4,
+ flightModel: "waterloo_penner",
+ groups: [],
+ };
+ await user.upload(
+ screen.getByLabelText("Import variation plan JSON"),
+ new File([planToJson(plan)], "ground-plan.json", { type: "application/json" }),
+ );
+
+ await user.click(screen.getByRole("button", { name: "Run Variation Study" }));
+
+ expect(screen.getByRole("status", { name: "Variation status" }))
+ .toHaveTextContent(/regional-ground.*not integrated.*browser/i);
+ expect(screen.getByRole("heading", { name: "Ready to Analyze Variation" }))
+ .toBeInTheDocument();
+ });
+
it("executes only the explicitly selected analyses", async () => {
const user = userEvent.setup();
- render();
+ render();
const runs = screen.getByRole("textbox", { name: "Runs" });
fireEvent.change(runs, { target: { value: "2" } });
fireEvent.blur(runs);
@@ -233,7 +297,7 @@ describe("VariationPanel analysis execution policy", () => {
it("renders every swing trial in the interactive arc inspector", async () => {
const user = userEvent.setup();
- render();
+ render();
await user.selectOptions(screen.getByRole("combobox", { name: "Pipeline" }), "swing");
fireEvent.change(screen.getByRole("textbox", { name: "Runs" }), {
target: { value: "2" },
diff --git a/src/rate_of_closure/web/src/components/VariationPanel.tsx b/src/rate_of_closure/web/src/components/VariationPanel.tsx
index f27eba810..6cdcda6ad 100644
--- a/src/rate_of_closure/web/src/components/VariationPanel.tsx
+++ b/src/rate_of_closure/web/src/components/VariationPanel.tsx
@@ -3,6 +3,8 @@ import { useState } from "react";
import type { SpatialTargetTs } from "../model/spatialTarget";
import { spatialTargetForGroundWorkflow } from "../model/spatialTargetWorkflow";
import {
+ GROUND_NORMAL_RESTITUTION_KEY,
+ GROUND_ROLLING_RESISTANCE_KEY,
planFromJson,
planToJson,
type VariationDatasetTs,
@@ -30,9 +32,6 @@ import { VariationActions } from "./VariationActions";
import { VariationPlanLibraryPanel } from "./VariationPlanLibraryPanel";
import { VariationResults } from "./VariationResults";
import { VariationSetup } from "./VariationSetup";
-import { defaultVariationPlan } from "./variationUi";
-import { DRIVER_TEE_HEIGHT_M } from "../model/ballSetup";
-import { loadBallSetupPreference } from "../model/ballSetupPersistence";
import { spatialTargetSummary } from "./spatialTargetPresentation";
let generatedPlanId = 0;
@@ -41,7 +40,20 @@ const createPlanId = (): string => {
return `variation-plan-${Date.now()}-${generatedPlanId}`;
};
+const GROUND_INPUTS = new Set([
+ GROUND_NORMAL_RESTITUTION_KEY,
+ GROUND_ROLLING_RESISTANCE_KEY,
+]);
+
+const hasRegionalGroundInput = (plan: VariationPlanTs): boolean =>
+ Object.keys(plan.baseVariables).some((key) => GROUND_INPUTS.has(key)) ||
+ plan.noise.some((spec) => GROUND_INPUTS.has(spec.variableKey));
+
export interface VariationPanelProps {
+ readonly plan: VariationPlanTs;
+ readonly analysisExecution: VariationAnalysisExecution;
+ readonly onPlanChange: (plan: VariationPlanTs) => void;
+ readonly onAnalysisExecutionChange: (value: VariationAnalysisExecution) => void;
spatialTarget?: SpatialTargetTs;
distanceUnit?: string;
/** Injectable persistent storage for tests, embedded hosts, and privacy modes. */
@@ -49,24 +61,18 @@ export interface VariationPanelProps {
}
export function VariationPanel({
+ plan,
+ analysisExecution,
+ onPlanChange,
+ onAnalysisExecutionChange,
spatialTarget,
distanceUnit = "yd",
storage,
-}: VariationPanelProps = {}): JSX.Element {
+}: VariationPanelProps): JSX.Element {
const targetUse = spatialTarget
? spatialTargetForGroundWorkflow(spatialTarget, "variation")
: { targetRegion: null, diagnostic: null };
const [initialLibrary] = useState(() => loadVariationPlanLibrary(storage));
- const [initialBallSetup] = useState(() => loadBallSetupPreference(
- storage,
- { supportMode: "tee", teeHeightM: DRIVER_TEE_HEIGHT_M },
- ).setup);
- const [plan, setPlan] = useState(() => ({
- ...defaultVariationPlan(),
- ballSetup: initialBallSetup,
- }));
- const [analysisExecution, setAnalysisExecution] =
- useState("both");
const [dataset, setDataset] = useState(null);
const [sensitivity, setSensitivity] = useState(null);
const [ensemble, setEnsemble] = useState(null);
@@ -95,6 +101,13 @@ export function VariationPanel({
const run = () => {
clearResults();
+ if (hasRegionalGroundInput(plan)) {
+ setStatus(
+ "Cannot run: regional-ground variation execution is not integrated " +
+ "into the browser scalar physics path.",
+ );
+ return;
+ }
try {
const runTogether = analysisExecution !== "individual";
const traceResult = plan.mode === "swing" && runTogether
@@ -131,7 +144,7 @@ export function VariationPanel({
const importPlan = (text: string) => {
try {
const loaded = planFromJson(text);
- setPlan(loaded);
+ onPlanChange(loaded);
clearResults();
setStatus(`Plan loaded with ${loaded.noise.length} noise rows and ${loaded.groups?.length ?? 0} groups.`);
} catch (error) {
@@ -151,7 +164,7 @@ export function VariationPanel({
const loadSelectedPlan = () => {
const selected = library.find((entry) => entry.id === selectedId);
if (!selected) return;
- setPlan(planFromJson(planToJson(selected.plan)));
+ onPlanChange(planFromJson(planToJson(selected.plan)));
setPlanName(selected.name);
clearResults();
setStatus(`Loaded named plan “${selected.name}”.`);
@@ -197,10 +210,10 @@ export function VariationPanel({
)}
{
- setAnalysisExecution(value);
+ onAnalysisExecutionChange(value);
clearResults();
}}
onConfigurationChange={clearResults}
diff --git a/src/rate_of_closure/web/src/components/hoverHints.test.tsx b/src/rate_of_closure/web/src/components/hoverHints.test.tsx
index b5644ba76..ab9cb3475 100644
--- a/src/rate_of_closure/web/src/components/hoverHints.test.tsx
+++ b/src/rate_of_closure/web/src/components/hoverHints.test.tsx
@@ -8,7 +8,8 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
-import App from "../App";
+import App from "../App";
+import { useRegionalGroundVariationWorkspace } from "../hooks/useRegionalGroundVariationWorkspace";
import { DEFAULT_SCENARIO } from "../model/impact";
import { DEFAULT_TARGET, spatialTargetFromRegion } from "../model/targets";
import { Derivation } from "./Derivation";
@@ -18,7 +19,15 @@ import { LaunchMonitorAnalyticsPanel } from "./LaunchMonitorAnalyticsPanel";
import { PlotsPanel } from "./PlotsPanel";
import { PuttingPanel } from "./PuttingPanel";
import { SimulationPanel } from "./SimulationPanel";
-import { VariationPanel } from "./VariationPanel";
+import { VariationPanel } from "./VariationPanel";
+
+function TestVariationPanel() {
+ const workspace = useRegionalGroundVariationWorkspace();
+ return ;
+}
beforeAll(() => {
// jsdom has no canvas implementation — stub the 2D context so the
@@ -83,7 +92,7 @@ describe("hover-hint completeness", () => {
});
it("VariationPanel", () => {
- const { container } = render();
+ const { container } = render();
assertHints(container, "VariationPanel");
});
diff --git a/src/rate_of_closure/web/src/components/variationUi.ts b/src/rate_of_closure/web/src/components/variationUi.ts
index 63508efdb..cc45ca74c 100644
--- a/src/rate_of_closure/web/src/components/variationUi.ts
+++ b/src/rate_of_closure/web/src/components/variationUi.ts
@@ -1,11 +1,8 @@
-import {
- keysForMode,
- variableDef,
- type NoiseSpecTs,
- type VariationMode,
- type VariationPlanTs,
-} from "../model/variation";
-import type { BallSetup } from "../model/ballSetup";
+import type { VariationMode } from "../model/variation";
+export {
+ defaultVariationPlan,
+ defaultVariationSpec as defaultSpec,
+} from "../model/variationDefaults";
export const MODE_LABELS: Record = {
delivery: "Delivery → Impact → Flight",
@@ -20,35 +17,6 @@ export const INPUT_CLASS =
export const BUTTON_CLASS =
"rounded border border-slate-700 bg-slate-800 px-3 py-1.5 text-sm text-slate-200 transition-colors hover:border-slate-500 disabled:opacity-40";
-export const defaultSpec = (
- mode: VariationMode,
- excluded: ReadonlySet = new Set(),
- ballSetup?: BallSetup,
-): NoiseSpecTs => {
- const keys = keysForMode(mode, ballSetup);
- const key = keys.find((candidate) => !excluded.has(candidate)) ?? keys[0];
- return {
- variableKey: key,
- distribution: "normal",
- scale: variableDef(key)?.typicalScale ?? 1,
- lower: null,
- upper: null,
- specId: key,
- timeWindowS: null,
- pointIds: [],
- };
-};
-
-export const defaultVariationPlan = (): VariationPlanTs => ({
- mode: "delivery",
- baseVariables: {},
- noise: [defaultSpec("delivery")],
- nRuns: 200,
- seed: 0,
- flightModel: "waterloo_penner",
- groups: [],
-});
-
export const downloadText = (name: string, text: string, type: string): void => {
downloadBlob(name, new Blob([text], { type }));
};
diff --git a/src/rate_of_closure/web/src/hooks/useRegionalGroundVariationWorkspace.ts b/src/rate_of_closure/web/src/hooks/useRegionalGroundVariationWorkspace.ts
new file mode 100644
index 000000000..32fa30059
--- /dev/null
+++ b/src/rate_of_closure/web/src/hooks/useRegionalGroundVariationWorkspace.ts
@@ -0,0 +1,78 @@
+import { useCallback, useMemo, useReducer } from "react";
+
+import { DRIVER_TEE_HEIGHT_M } from "../model/ballSetup";
+import { loadBallSetupPreference } from "../model/ballSetupPersistence";
+import type { GroundRegionalMaterialPlanRequest } from "../model/groundRegionalPlan";
+import {
+ applyRegionalGroundVariationRequest,
+ composeRegionalGroundVariationRequest,
+ createRegionalGroundVariationWorkspaceState,
+ regionalGroundVariationWorkspaceReducer,
+ type RegionalGroundVariationRequestTs,
+ type RegionalGroundVariationRequestPort,
+ type RegionalGroundVariationWorkspaceState,
+} from "../model/regionalGroundVariationWorkspace";
+import type { RegionalSurfacePlanDraft } from "../model/regionalSurfacePlan";
+import type { VariationPlanTs } from "../model/variation";
+import type { VariationAnalysisExecution } from "../model/variationAnalysisPolicy";
+import { defaultVariationPlan } from "../model/variationDefaults";
+
+export interface RegionalGroundVariationWorkspaceController {
+ readonly state: RegionalGroundVariationWorkspaceState;
+ readonly replaceVariationPlan: (plan: VariationPlanTs) => void;
+ readonly replaceAnalysisExecution: (value: VariationAnalysisExecution) => void;
+ readonly replaceRegionalDraft: (draft: RegionalSurfacePlanDraft) => void;
+ readonly applyRegionalImport: (request: GroundRegionalMaterialPlanRequest) => void;
+ readonly requestPort: RegionalGroundVariationRequestPort;
+}
+
+const initialState = (storage?: Storage): RegionalGroundVariationWorkspaceState => {
+ const setup = loadBallSetupPreference(
+ storage,
+ { supportMode: "tee", teeHeightM: DRIVER_TEE_HEIGHT_M },
+ ).setup;
+ return createRegionalGroundVariationWorkspaceState({
+ ...defaultVariationPlan(),
+ ballSetup: setup,
+ });
+};
+
+/** Own both request editors above mutually exclusive workspace panels. */
+export function useRegionalGroundVariationWorkspace(
+ storage?: Storage,
+): RegionalGroundVariationWorkspaceController {
+ const [state, dispatch] = useReducer(
+ regionalGroundVariationWorkspaceReducer,
+ storage,
+ initialState,
+ );
+ const replaceVariationPlan = useCallback((plan: VariationPlanTs) => {
+ dispatch({ type: "replace_variation_plan", plan });
+ }, []);
+ const replaceAnalysisExecution = useCallback((value: VariationAnalysisExecution) => {
+ dispatch({ type: "replace_analysis_execution", analysisExecution: value });
+ }, []);
+ const replaceRegionalDraft = useCallback((draft: RegionalSurfacePlanDraft) => {
+ dispatch({ type: "replace_regional_draft", draft });
+ }, []);
+ const applyRegionalImport = useCallback((request: GroundRegionalMaterialPlanRequest) => {
+ dispatch({ type: "apply_regional_import", request });
+ }, []);
+ const snapshot = useCallback(
+ () => composeRegionalGroundVariationRequest(state),
+ [state],
+ );
+ const apply = useCallback((request: RegionalGroundVariationRequestTs) => {
+ applyRegionalGroundVariationRequest(state, request);
+ dispatch({ type: "apply_request", request });
+ }, [state]);
+ const requestPort = useMemo(() => ({ snapshot, apply }), [snapshot, apply]);
+ return {
+ state,
+ replaceVariationPlan,
+ replaceAnalysisExecution,
+ replaceRegionalDraft,
+ applyRegionalImport,
+ requestPort,
+ };
+}
diff --git a/src/rate_of_closure/web/src/model/regionalGroundVariationWorkspace.test.ts b/src/rate_of_closure/web/src/model/regionalGroundVariationWorkspace.test.ts
new file mode 100644
index 000000000..2a619fa41
--- /dev/null
+++ b/src/rate_of_closure/web/src/model/regionalGroundVariationWorkspace.test.ts
@@ -0,0 +1,146 @@
+import { describe, expect, it } from "vitest";
+
+import { illustrativeRegionalSurfacePlanDraft } from "./regionalSurfacePlan";
+import {
+ GROUND_NORMAL_RESTITUTION_KEY,
+ GROUND_ROLLING_RESISTANCE_KEY,
+ applyRegionalGroundVariationRequest,
+ composeRegionalGroundVariationRequest,
+ createRegionalGroundVariationWorkspaceState,
+ regionalGroundVariationWorkspaceReducer,
+ type RegionalGroundVariationRequestTs,
+} from "./regionalGroundVariationWorkspace";
+import type { VariationPlanTs } from "./variation";
+
+const groundPlan = (runs = 12): VariationPlanTs => ({
+ mode: "launch",
+ baseVariables: {
+ [GROUND_NORMAL_RESTITUTION_KEY]: 0.42,
+ [GROUND_ROLLING_RESISTANCE_KEY]: 0.04,
+ },
+ noise: [
+ {
+ variableKey: GROUND_NORMAL_RESTITUTION_KEY,
+ distribution: "normal",
+ scale: 0.02,
+ lower: 0.1,
+ upper: 0.8,
+ specId: GROUND_NORMAL_RESTITUTION_KEY,
+ timeWindowS: null,
+ pointIds: [],
+ },
+ ],
+ nRuns: runs,
+ seed: 91,
+ flightModel: "waterloo_penner",
+ groups: [],
+});
+
+const request = (): RegionalGroundVariationRequestTs => {
+ const state = createRegionalGroundVariationWorkspaceState();
+ let prepared = regionalGroundVariationWorkspaceReducer(state, {
+ type: "replace_variation_plan",
+ plan: groundPlan(),
+ });
+ prepared = regionalGroundVariationWorkspaceReducer(prepared, {
+ type: "replace_regional_draft",
+ draft: prepared.regionalDraft,
+ });
+ return composeRegionalGroundVariationRequest(prepared);
+};
+
+describe("regional ground variation workspace", () => {
+ it("never composes the disclosed illustrative draft as user evidence", () => {
+ const state = regionalGroundVariationWorkspaceReducer(
+ createRegionalGroundVariationWorkspaceState(),
+ { type: "replace_variation_plan", plan: groundPlan() },
+ );
+
+ expect(() => composeRegionalGroundVariationRequest(state))
+ .toThrow(/explicitly edited or imported/i);
+ });
+
+ it("composes the current variation and regional editors without substituting state", () => {
+ let state = createRegionalGroundVariationWorkspaceState();
+ const editedDraft = {
+ ...illustrativeRegionalSurfacePlanDraft(),
+ request_id: "user-edited-course",
+ };
+ state = regionalGroundVariationWorkspaceReducer(state, {
+ type: "replace_variation_plan",
+ plan: groundPlan(),
+ });
+ state = regionalGroundVariationWorkspaceReducer(state, {
+ type: "replace_regional_draft",
+ draft: editedDraft,
+ });
+
+ const snapshot = composeRegionalGroundVariationRequest(state);
+
+ expect(snapshot.plan).toBe(state.variationPlan);
+ expect(snapshot.regionalPlan.request_id).toBe("user-edited-course");
+ expect(snapshot.regionalPlan.base_surface.normal_restitution).toBe(0.42);
+ expect(snapshot.resultId).toBe(state.requestIdentity.resultId);
+ });
+
+ it("applies a complete request transactionally and retains exact import evidence", () => {
+ const imported = request();
+ const previous = createRegionalGroundVariationWorkspaceState();
+
+ const next = applyRegionalGroundVariationRequest(previous, imported);
+
+ expect(next.variationPlan).toBe(imported.plan);
+ expect(next.importedRegionalRequest).toBe(imported.regionalPlan);
+ expect(next.regionalDraftOrigin).toBe("imported");
+ expect(next.regionalDraft.request_id).toBe(imported.regionalPlan.request_id);
+ expect(next.requestIdentity).toEqual({
+ resultId: imported.resultId,
+ sourceProvenance: imported.sourceProvenance,
+ maxRows: imported.maxRows,
+ seriesId: imported.seriesId,
+ });
+ });
+
+ it("retains an exact regional import while deriving its controlled draft", () => {
+ const imported = request().regionalPlan;
+ const state = createRegionalGroundVariationWorkspaceState();
+
+ const next = regionalGroundVariationWorkspaceReducer(state, {
+ type: "apply_regional_import",
+ request: imported,
+ });
+
+ expect(next.importedRegionalRequest).toBe(imported);
+ expect(next.regionalDraft.request_id).toBe(imported.request_id);
+ });
+
+ it("rejects an invalid apply before changing any workspace field", () => {
+ const previous = createRegionalGroundVariationWorkspaceState();
+ const invalid = { ...request(), maxRows: 1 };
+
+ expect(() => applyRegionalGroundVariationRequest(previous, invalid))
+ .toThrow(/nRuns exceeds maxRows/i);
+ expect(previous).toEqual(createRegionalGroundVariationWorkspaceState());
+ });
+
+ it("rejects a mismatched material base when composing", () => {
+ let state = createRegionalGroundVariationWorkspaceState();
+ state = regionalGroundVariationWorkspaceReducer(state, {
+ type: "replace_regional_draft",
+ draft: state.regionalDraft,
+ });
+ state = regionalGroundVariationWorkspaceReducer(state, {
+ type: "replace_variation_plan",
+ plan: {
+ ...groundPlan(),
+ baseVariables: {
+ [GROUND_NORMAL_RESTITUTION_KEY]: 0.7,
+ [GROUND_ROLLING_RESISTANCE_KEY]: 0.04,
+ },
+ },
+ });
+
+ expect(() => composeRegionalGroundVariationRequest(state))
+ .toThrow(/normal restitution base does not match/i);
+ });
+});
diff --git a/src/rate_of_closure/web/src/model/regionalGroundVariationWorkspace.ts b/src/rate_of_closure/web/src/model/regionalGroundVariationWorkspace.ts
new file mode 100644
index 000000000..76e61f298
--- /dev/null
+++ b/src/rate_of_closure/web/src/model/regionalGroundVariationWorkspace.ts
@@ -0,0 +1,228 @@
+import type { VariationAnalysisExecution } from "./variationAnalysisPolicy";
+import type { GroundRegionalMaterialPlanRequest } from "./groundRegionalPlan";
+import {
+ editorDraftFromGroundRegionalSurfacePlanRequest,
+ illustrativeRegionalSurfacePlanDraft,
+ regionalSurfacePlanRequestForDraft,
+ type RegionalSurfacePlanDraft,
+} from "./regionalSurfacePlan";
+import {
+ GROUND_NORMAL_RESTITUTION_KEY,
+ GROUND_ROLLING_RESISTANCE_KEY,
+ validatePlan,
+ type VariationPlanTs,
+} from "./variation";
+import { defaultVariationPlan } from "./variationDefaults";
+
+export {
+ GROUND_NORMAL_RESTITUTION_KEY,
+ GROUND_ROLLING_RESISTANCE_KEY,
+};
+
+export const MAX_REGIONAL_GROUND_STUDY_ROWS = 100_000;
+
+export interface RegionalGroundVariationRequestIdentity {
+ readonly resultId: string;
+ readonly sourceProvenance: string;
+ readonly maxRows: number;
+ readonly seriesId: string | null;
+}
+
+export interface RegionalGroundVariationRequestTs {
+ readonly plan: VariationPlanTs;
+ readonly regionalPlan: GroundRegionalMaterialPlanRequest;
+ readonly resultId: string;
+ readonly sourceProvenance: string;
+ readonly maxRows: number;
+ readonly seriesId: string | null;
+}
+
+export interface RegionalGroundVariationRequestPort {
+ readonly snapshot: () => RegionalGroundVariationRequestTs;
+ readonly apply: (request: RegionalGroundVariationRequestTs) => void;
+}
+
+export interface RegionalGroundVariationWorkspaceState {
+ readonly variationPlan: VariationPlanTs;
+ readonly analysisExecution: VariationAnalysisExecution;
+ readonly regionalDraft: RegionalSurfacePlanDraft;
+ readonly importedRegionalRequest: GroundRegionalMaterialPlanRequest | null;
+ readonly regionalDraftOrigin: "illustrative" | "user_edited" | "imported";
+ readonly requestIdentity: RegionalGroundVariationRequestIdentity;
+}
+
+export type RegionalGroundVariationWorkspaceAction =
+ | { readonly type: "replace_variation_plan"; readonly plan: VariationPlanTs }
+ | {
+ readonly type: "replace_analysis_execution";
+ readonly analysisExecution: VariationAnalysisExecution;
+ }
+ | { readonly type: "replace_regional_draft"; readonly draft: RegionalSurfacePlanDraft }
+ | {
+ readonly type: "apply_regional_import";
+ readonly request: GroundRegionalMaterialPlanRequest;
+ }
+ | { readonly type: "apply_request"; readonly request: RegionalGroundVariationRequestTs };
+
+const DEFAULT_IDENTITY: RegionalGroundVariationRequestIdentity = Object.freeze({
+ resultId: "regional-ground-variation-result",
+ sourceProvenance: "rate-web-interactive-workspace",
+ maxRows: 500,
+ seriesId: null,
+});
+
+const exactKeys = (value: Record, expected: readonly string[]): boolean => {
+ const actual = Object.keys(value).sort();
+ const wanted = [...expected].sort();
+ return actual.length === expected.length &&
+ actual.every((key, index) => key === wanted[index]);
+};
+
+const nonblank = (value: string, name: string): void => {
+ if (value.trim().length === 0) throw new RangeError(`${name} must be nonempty`);
+};
+
+const validateGroundPlan = (
+ plan: VariationPlanTs,
+ regionalPlan: GroundRegionalMaterialPlanRequest,
+): void => {
+ validatePlan(plan);
+ if (plan.mode !== "launch") {
+ throw new RangeError("ground material variation requires launch mode");
+ }
+ if (plan.ballSetup !== undefined) {
+ throw new RangeError("regional-ground request plans cannot include ballSetup");
+ }
+ const supported = [
+ GROUND_NORMAL_RESTITUTION_KEY,
+ GROUND_ROLLING_RESISTANCE_KEY,
+ ];
+ if (!exactKeys(plan.baseVariables, supported)) {
+ throw new RangeError("unsupported ground base key");
+ }
+ for (const spec of plan.noise) {
+ if (!supported.includes(spec.variableKey)) {
+ throw new RangeError("unsupported ground variation key");
+ }
+ if ((spec.timeWindowS !== null && spec.timeWindowS !== undefined) ||
+ (spec.pointIds?.length ?? 0) > 0) {
+ throw new RangeError("ground material variation must be global");
+ }
+ if (spec.lower === null || spec.upper === null ||
+ spec.lower < 0 || spec.lower >= spec.upper || spec.upper > 1) {
+ throw new RangeError("ground material bounds must be explicit within [0, 1]");
+ }
+ const base = plan.baseVariables[spec.variableKey];
+ if (base < spec.lower || base > spec.upper) {
+ throw new RangeError("ground material base must lie within bounds");
+ }
+ }
+ if (plan.baseVariables[GROUND_NORMAL_RESTITUTION_KEY] !==
+ regionalPlan.base_surface.normal_restitution) {
+ throw new RangeError("normal restitution base does not match regional plan");
+ }
+ if (plan.baseVariables[GROUND_ROLLING_RESISTANCE_KEY] !==
+ regionalPlan.base_surface.rolling_resistance) {
+ throw new RangeError("rolling resistance base does not match regional plan");
+ }
+};
+
+const validateRequest = (request: RegionalGroundVariationRequestTs): void => {
+ nonblank(request.resultId, "resultId");
+ nonblank(request.sourceProvenance, "sourceProvenance");
+ if (!Number.isInteger(request.maxRows) || request.maxRows < 1 ||
+ request.maxRows > MAX_REGIONAL_GROUND_STUDY_ROWS) {
+ throw new RangeError("maxRows is outside the supported range");
+ }
+ if (request.seriesId !== null) nonblank(request.seriesId, "seriesId");
+ if (request.plan.nRuns > request.maxRows) {
+ throw new RangeError("plan nRuns exceeds maxRows");
+ }
+ validateGroundPlan(request.plan, request.regionalPlan);
+};
+
+/** Build one fresh owner state for both editors. */
+export const createRegionalGroundVariationWorkspaceState = (
+ variationPlan: VariationPlanTs = defaultVariationPlan(),
+): RegionalGroundVariationWorkspaceState => ({
+ variationPlan,
+ analysisExecution: "both",
+ regionalDraft: illustrativeRegionalSurfacePlanDraft(),
+ importedRegionalRequest: null,
+ regionalDraftOrigin: "illustrative",
+ requestIdentity: DEFAULT_IDENTITY,
+});
+
+/** Compose and validate the exact current editor state without fallback values. */
+export const composeRegionalGroundVariationRequest = (
+ state: RegionalGroundVariationWorkspaceState,
+): RegionalGroundVariationRequestTs => {
+ if (state.regionalDraftOrigin === "illustrative") {
+ throw new RangeError(
+ "regional plan must be explicitly edited or imported before composition",
+ );
+ }
+ const request = {
+ plan: state.variationPlan,
+ regionalPlan: regionalSurfacePlanRequestForDraft(
+ state.regionalDraft,
+ state.importedRegionalRequest,
+ ),
+ ...state.requestIdentity,
+ };
+ validateRequest(request);
+ return request;
+};
+
+/** Validate a complete request before returning the replacement owner state. */
+export const applyRegionalGroundVariationRequest = (
+ previous: RegionalGroundVariationWorkspaceState,
+ request: RegionalGroundVariationRequestTs,
+): RegionalGroundVariationWorkspaceState => {
+ validateRequest(request);
+ const draft = editorDraftFromGroundRegionalSurfacePlanRequest(request.regionalPlan);
+ return {
+ ...previous,
+ variationPlan: request.plan,
+ regionalDraft: draft,
+ importedRegionalRequest: request.regionalPlan,
+ regionalDraftOrigin: "imported",
+ requestIdentity: {
+ resultId: request.resultId,
+ sourceProvenance: request.sourceProvenance,
+ maxRows: request.maxRows,
+ seriesId: request.seriesId,
+ },
+ };
+};
+
+/** Pure transactional state transition shared by the App-owned hook and tests. */
+export const regionalGroundVariationWorkspaceReducer = (
+ state: RegionalGroundVariationWorkspaceState,
+ action: RegionalGroundVariationWorkspaceAction,
+): RegionalGroundVariationWorkspaceState => {
+ switch (action.type) {
+ case "replace_variation_plan":
+ return { ...state, variationPlan: action.plan };
+ case "replace_analysis_execution":
+ return { ...state, analysisExecution: action.analysisExecution };
+ case "replace_regional_draft":
+ return {
+ ...state,
+ regionalDraft: action.draft,
+ regionalDraftOrigin: "user_edited",
+ };
+ case "apply_regional_import":
+ {
+ const draft = editorDraftFromGroundRegionalSurfacePlanRequest(action.request);
+ return {
+ ...state,
+ regionalDraft: draft,
+ importedRegionalRequest: action.request,
+ regionalDraftOrigin: "imported",
+ };
+ }
+ case "apply_request":
+ return applyRegionalGroundVariationRequest(state, action.request);
+ }
+};
diff --git a/src/rate_of_closure/web/src/model/variation.test.ts b/src/rate_of_closure/web/src/model/variation.test.ts
index e826a67fd..7ef383722 100644
--- a/src/rate_of_closure/web/src/model/variation.test.ts
+++ b/src/rate_of_closure/web/src/model/variation.test.ts
@@ -260,7 +260,7 @@ describe("analysis + export", () => {
});
it("registry keys per mode mirror the Python categories", () => {
- expect(keysForMode("launch")).toHaveLength(5);
+ expect(keysForMode("launch")).toHaveLength(7);
expect(keysForMode("delivery")).toHaveLength(7); // club category is desktop-only
});
});
diff --git a/src/rate_of_closure/web/src/model/variation.ts b/src/rate_of_closure/web/src/model/variation.ts
index fc9976b17..3e58d5511 100644
--- a/src/rate_of_closure/web/src/model/variation.ts
+++ b/src/rate_of_closure/web/src/model/variation.ts
@@ -24,6 +24,8 @@ export {
CATEGORY_DELIVERY,
CATEGORY_LAUNCH,
CATEGORY_SWING,
+ GROUND_NORMAL_RESTITUTION_KEY,
+ GROUND_ROLLING_RESISTANCE_KEY,
keysForMode,
variableDef,
variableLabel,
diff --git a/src/rate_of_closure/web/src/model/variationDefaults.ts b/src/rate_of_closure/web/src/model/variationDefaults.ts
new file mode 100644
index 000000000..4ca56e882
--- /dev/null
+++ b/src/rate_of_closure/web/src/model/variationDefaults.ts
@@ -0,0 +1,39 @@
+import type { BallSetup } from "./ballSetup";
+import {
+ keysForMode,
+ variableDef,
+ type NoiseSpecTs,
+ type VariationMode,
+ type VariationPlanTs,
+} from "./variation";
+
+/** Build the first available independent noise specification for one mode. */
+export const defaultVariationSpec = (
+ mode: VariationMode,
+ excluded: ReadonlySet = new Set(),
+ ballSetup?: BallSetup,
+): NoiseSpecTs => {
+ const keys = keysForMode(mode, ballSetup);
+ const key = keys.find((candidate) => !excluded.has(candidate)) ?? keys[0];
+ return {
+ variableKey: key,
+ distribution: "normal",
+ scale: variableDef(key)?.typicalScale ?? 1,
+ lower: null,
+ upper: null,
+ specId: key,
+ timeWindowS: null,
+ pointIds: [],
+ };
+};
+
+/** Return a fresh browser variation-plan draft. */
+export const defaultVariationPlan = (): VariationPlanTs => ({
+ mode: "delivery",
+ baseVariables: {},
+ noise: [defaultVariationSpec("delivery")],
+ nRuns: 200,
+ seed: 0,
+ flightModel: "waterloo_penner",
+ groups: [],
+});
diff --git a/src/rate_of_closure/web/src/model/variationRegistry.ts b/src/rate_of_closure/web/src/model/variationRegistry.ts
index 0eca76407..79a5a0a0b 100644
--- a/src/rate_of_closure/web/src/model/variationRegistry.ts
+++ b/src/rate_of_closure/web/src/model/variationRegistry.ts
@@ -13,6 +13,10 @@ export const CATEGORY_LAUNCH = "swing_sim.flight.launch";
export const CATEGORY_SWING = "swing_sim.swing";
export const CATEGORY_CLUB = "swing_sim.club";
export const TEE_HEIGHT_VARIATION_KEY = "swing_sim.ball_setup.tee_height_m";
+export const GROUND_NORMAL_RESTITUTION_KEY =
+ `${CATEGORY_LAUNCH}.ground_normal_restitution`;
+export const GROUND_ROLLING_RESISTANCE_KEY =
+ `${CATEGORY_LAUNCH}.ground_rolling_resistance`;
export type VariationMode = "delivery" | "swing" | "launch";
@@ -198,15 +202,31 @@ export const VARIABLE_REGISTRY: VariableDefTs[] = [
typicalScale: 100.0,
guidance: "Typical shot-to-shot variation: 50-300 rpm.",
},
- {
- key: `${CATEGORY_LAUNCH}.spin_axis_deg`,
+ {
+ key: `${CATEGORY_LAUNCH}.spin_axis_deg`,
label: "Spin-Axis Tilt",
unit: "deg",
default: 0.0,
typicalScale: 1.5,
- guidance: "Positive = fade/slice side.",
- },
-];
+ guidance: "Positive = fade/slice side.",
+ },
+ {
+ key: GROUND_NORMAL_RESTITUTION_KEY,
+ label: "Ground Normal Restitution",
+ unit: "1",
+ default: 0.4,
+ typicalScale: 0.05,
+ guidance: "Base-surface normal restitution for a regional-ground study.",
+ },
+ {
+ key: GROUND_ROLLING_RESISTANCE_KEY,
+ label: "Ground Rolling Resistance",
+ unit: "1",
+ default: 0.04,
+ typicalScale: 0.01,
+ guidance: "Base-surface rolling resistance for a regional-ground study.",
+ },
+];
const REGISTRY_BY_KEY = new Map(VARIABLE_REGISTRY.map((d) => [d.key, d]));