@helix3/helix-cli 0.1.14-helix3.201 → 0.1.14-helix3.202
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/plugins/beamng-vehicle/README.md +6 -2
- package/plugins/beamng-vehicle/bin/__pycache__/beamng_expression.cpython-312.pyc +0 -0
- package/plugins/beamng-vehicle/bin/__pycache__/beamng_jbeam.cpython-312.pyc +0 -0
- package/plugins/beamng-vehicle/bin/__pycache__/beamng_materials.cpython-312.pyc +0 -0
- package/plugins/beamng-vehicle/bin/beamng-vehicle.mjs +222 -3
- package/plugins/beamng-vehicle/bin/lower-beamng.py +549 -76
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@helix3/helix-cli",
|
|
3
|
-
"version": "0.1.14-helix3.
|
|
3
|
+
"version": "0.1.14-helix3.202",
|
|
4
4
|
"description": "helix — the HELIX creator CLI: scaffold, validate, and publish Instant Worlds",
|
|
5
5
|
"main": "dist/lib.js",
|
|
6
6
|
"types": "dist/lib.d.ts",
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
"@gltf-transform/extensions": "^4.4.0",
|
|
64
64
|
"@gltf-transform/functions": "^4.4.0",
|
|
65
65
|
"@hypersoniclabs/helix-manifest": "npm:@helix3/helix-manifest@0.3.25-helix3.68",
|
|
66
|
-
"@hypersoniclabs/helix-sdk": "npm:@helix3/helix-sdk@0.1.
|
|
66
|
+
"@hypersoniclabs/helix-sdk": "npm:@helix3/helix-sdk@0.1.6-helix3.101",
|
|
67
67
|
"commander": "^13.0.0",
|
|
68
68
|
"ktx2-encoder": "^0.5.3",
|
|
69
69
|
"meshoptimizer": "^0.22.0",
|
|
@@ -43,8 +43,12 @@ The converter reads all four BeamNG material stages. Later authored stages win,
|
|
|
43
43
|
|
|
44
44
|
Each successful terminal package emits six assets: the canonical GLB, `<vehicle>.vehicle-source.json`, the versioned `<vehicle>.vehicle-lowering.json` diagnostic aggregate, `<vehicle>.vehicle-package.json`, `<vehicle>.vehicle-addons.json`, and `<vehicle>.vehicle-build.json`. The lowering aggregate makes source semantics, structural exclusions, runtime blockers, functional-domain status, and provenance available to CLI and agent consumers instead of discarding them after packaging. The source manifest remains embedded in GLB extras and records the exact configuration and slot closure, fingerprints, material-resolution provenance, and coverage ledger.
|
|
45
45
|
|
|
46
|
-
`lower-beamng.py` compiles the exact selected `.pc` and admitted JBeam closure into `helix.vehicle-package/2`, stable add-on records, and a source Build. Source literals cover the torque curve, idle/limiter, engine losses, gearbox and final drive, differentials, selected front/rear `pressureWheels` brake
|
|
46
|
+
`lower-beamng.py` compiles the exact selected `.pc` and admitted JBeam closure into `helix.vehicle-package/2`, stable add-on records, and a source Build. Source literals cover the torque curve, idle/limiter, engine losses, gearbox and final drive, differentials, selected front/rear `pressureWheels` tire/brake values, lights/electrics, audio event references, seats/cameras, and rigid openables when present. Brake, suspension, tire, and differential expressions use a bounded data-only resolver for selected numeric variables, arithmetic, comparisons, boolean conditionals, and `case()`; direct selected-variable references are supported, source code is never evaluated, and unknown variables, calls, or tokens remain explicitly unsupported. HELIX total brake torque is derived from BeamNG's per-wheel axle values and the exact `.pc` variable overrides take precedence over selected JBeam defaults.
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
Driveline topology is derived from the selected device graph: each connected wheel is traced upstream to the exact selected gearbox, so FWD and RWD packages contain only their driven axle and AWD requires an actual common centre differential. Duplicate devices, cycles, half-connected axles, missing final drives, unknown differential types, invalid fields, and ambiguous output ports retain source receipts but suppress runtime differentials instead of inventing a topology. Open, locked, LSD, and unit-exponent viscous devices lower exactly when every selected behavior field is representable. Nonzero BeamNG torque-loss, virtual-inertia, speed-limit, active-lock/controller, and non-unit viscous-exponent semantics remain explicit parity blockers; a topology-safe approximation stays runnable with `runtimeStatus=approximate` while `functionalDomains.differentials=false`.
|
|
49
|
+
|
|
50
|
+
Selected tires lower one exact common or vehicle-local leaf per axle. Pressure, radius, width, friction, sliding friction, and load-sensitivity values keep literal/derived/unsupported receipts. HELIX's engine-wide exponential load kernel is deterministically sampled at 24 log-spaced points from 100 N to 40 kN for its compact `muLut`; Pacejka-style shape coefficients remain explicitly identified HELIX priors. Complete selected extraction sets `extractionComplete=true` and uses the selected radius/load data, but remains `functionalDomains.wheelsTyres=false` because the current runtime has no pressure or tire-width fields and still uses shape priors. Missing, duplicate, invalid, or unresolved tire semantics instead produce a complete prior runtime fallback, never a parity claim.
|
|
51
|
+
|
|
52
|
+
Selected suspension evidence is lowered only from the chosen strut/coilover leaf part on each axle. Matching left/right spring, slow/fast bump, slow/fast rebound, velocity-split, precompression, and bound rows are retained with beam-space frequency and damping-ratio derivations. Structural parent/subframe beams and swaybar construction beams are explicitly excluded by part custody. Selected torsionbar stiffness is retained in `N.m/rad`, but the runtime suspension remains fail-closed: BeamNG element rates need motion-ratio geometry to become HELIX wheel rates, and the current package has one global damping triplet with no axle-specific fast/slow channels. The emitted HELIX suspension therefore remains an explicit fallback and `functionalDomains.suspension` remains false until those exact runtime mappings exist. Raw Lua/controller and other unsupported expressions remain explicit provenance instead of being presented as literal runtime parity. Soft-body deformation, fluids, wear, and detailed damage remain out of scope. Rigid panel poses are semantic on every device, physical bodies begin at `medium`, and detachment remains disabled on every production tier while source break groups are retained as future eligibility. Simulation, authority, package identity, and Build selection are quality-invariant. Bridge currently seals one canonical source GLB and truthfully maps every tier to it; optimized mesh/texture rendition IDs must not be selected until the optimizer has materialized and sealed those artifacts.
|
|
49
53
|
|
|
50
54
|
The engine consumer seam is `vehiclePackageToTuning(package, { landSolver: 'v2' })`. The source Build intentionally contains stable source identities rather than invented Continuum CIDs; after publication resolves immutable Package references, the existing `createPrivateBuildDraft` → `freezeBuildDraft` workflow is the canonical Build seam.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -529,7 +529,7 @@ function assertValidFunctionalLowering(lowering, boundary) {
|
|
|
529
529
|
const nonempty = (value) => typeof value === 'string' && value.length > 0;
|
|
530
530
|
const explicitUnsupported = (value) => record(value) && value.status === 'unsupported' && nonempty(value.reason);
|
|
531
531
|
const semanticReceipt = (value) => record(value)
|
|
532
|
-
&& ['literal', 'derived', 'unsupported'].includes(value.status) && nonempty(value.unit)
|
|
532
|
+
&& ['literal', 'derived', 'defaulted', 'unsupported'].includes(value.status) && nonempty(value.unit)
|
|
533
533
|
&& Array.isArray(value.variables)
|
|
534
534
|
&& (value.status === 'unsupported' ? nonempty(value.reason) : Number.isFinite(value.value));
|
|
535
535
|
const selectedReceipt = (value) => semanticReceipt(value) && (value.status === 'unsupported'
|
|
@@ -559,6 +559,198 @@ function assertValidFunctionalLowering(lowering, boundary) {
|
|
|
559
559
|
const antiRollAxle = (value) => explicitUnsupported(value) || (record(value)
|
|
560
560
|
&& nonempty(value.slotType) && nonempty(value.partName) && nonempty(value.source)
|
|
561
561
|
&& torsionReceipt(value.torsionSpring) && value.runtimeStatus === 'unsupported' && nonempty(value.reason));
|
|
562
|
+
const tyreFields = ['pressurePSI', 'radius', 'tireWidth', 'frictionCoef', 'slidingFrictionCoef',
|
|
563
|
+
'noLoadCoef', 'fullLoadCoef', 'loadSensitivitySlope'];
|
|
564
|
+
const tyreAxle = (value) => {
|
|
565
|
+
if (explicitUnsupported(value) && Array.isArray(value.candidates)) return true;
|
|
566
|
+
if (!record(value) || !nonempty(value.partName) || !nonempty(value.source)
|
|
567
|
+
|| !tyreFields.every((name) => semanticReceipt(value[name]) && Object.hasOwn(value[name], 'raw'))) return false;
|
|
568
|
+
const expectedStatus = tyreFields.every((name) => value[name].status !== 'unsupported')
|
|
569
|
+
? 'functional' : 'unsupported';
|
|
570
|
+
return value.status === expectedStatus;
|
|
571
|
+
};
|
|
572
|
+
const tyres = semantics?.wheelsTyres;
|
|
573
|
+
const tyreFormula = 'frictionCoef * (fullLoadCoef + (noLoadCoef - fullLoadCoef) * exp(-loadSensitivitySlope * fz))';
|
|
574
|
+
const expectedTyreLoads = Array.from({ length: 24 }, (_, index) => Math.round(Math.exp(
|
|
575
|
+
Math.log(100) + ((Math.log(40000) - Math.log(100)) * index) / 23,
|
|
576
|
+
) * 1e9) / 1e9);
|
|
577
|
+
const selectedTyresComplete = ['front', 'rear'].every((axle) => record(tyres?.axles?.[axle])
|
|
578
|
+
&& tyres.axles[axle].status === 'functional'
|
|
579
|
+
&& tyreFields.every((name) => tyres.axles[axle][name]?.status !== 'unsupported'));
|
|
580
|
+
const runtimeTyres = lowering?.vehiclePackage?.simulation?.land?.tyreByAxle;
|
|
581
|
+
const approximately = (left, right, epsilon = 1e-8) => Number.isFinite(left) && Number.isFinite(right)
|
|
582
|
+
&& Math.abs(left - right) <= epsilon;
|
|
583
|
+
const tyreShapePrior = { alphaPeakRad: 0.139626, cxMult: 1.05, sigmaX: 0.048,
|
|
584
|
+
sigmaY: 0.08, camberGain: 0.15, dxOverDy: 1, sFall: 0.3,
|
|
585
|
+
rollingResistance: 0.013, angularInertia: 1.3 };
|
|
586
|
+
const validRuntimeTyre = (runtime, axle) => {
|
|
587
|
+
const source = tyres?.axles?.[axle];
|
|
588
|
+
if (!record(runtime) || !record(source) || !record(runtime.loadSensitivity)
|
|
589
|
+
|| !record(runtime.muLut) || runtime.calibration !== 'prior'
|
|
590
|
+
|| !approximately(runtime.radius, source.radius?.value)
|
|
591
|
+
|| !Array.isArray(runtime.muLut.fz) || !equalJson(runtime.muLut.fz, tyres?.lut?.fz)
|
|
592
|
+
|| !Array.isArray(runtime.muLut.mu) || runtime.muLut.mu.length !== runtime.muLut.fz.length
|
|
593
|
+
|| !Object.entries(tyreShapePrior).every(([name, value]) => approximately(runtime[name], value))) return false;
|
|
594
|
+
for (const field of ['frictionCoef', 'slidingFrictionCoef', 'noLoadCoef', 'fullLoadCoef', 'loadSensitivitySlope']) {
|
|
595
|
+
if (!approximately(runtime.loadSensitivity[field], source[field]?.value)) return false;
|
|
596
|
+
}
|
|
597
|
+
const slideRatio = source.slidingFrictionCoef.value / source.frictionCoef.value;
|
|
598
|
+
if (!approximately(runtime.muLut.slideRatio, Math.round(slideRatio * 1e6) / 1e6)) return false;
|
|
599
|
+
return runtime.muLut.fz.every((load, index) => approximately(runtime.muLut.mu[index],
|
|
600
|
+
Math.round((source.frictionCoef.value * (source.fullLoadCoef.value
|
|
601
|
+
+ (source.noLoadCoef.value - source.fullLoadCoef.value)
|
|
602
|
+
* Math.exp(-source.loadSensitivitySlope.value * load))) * 1e9) / 1e9));
|
|
603
|
+
};
|
|
604
|
+
const validFallbackTyre = (runtime) => record(runtime) && record(runtime.loadSensitivity)
|
|
605
|
+
&& record(runtime.muLut) && runtime.calibration === 'prior'
|
|
606
|
+
&& approximately(runtime.radius, 0.3466)
|
|
607
|
+
&& Object.entries(tyreShapePrior).every(([name, value]) => approximately(runtime[name], value))
|
|
608
|
+
&& equalJson(runtime.muLut.fz, tyres?.lut?.fz)
|
|
609
|
+
&& Array.isArray(runtime.muLut.mu) && Array.isArray(runtime.muLut.fz)
|
|
610
|
+
&& runtime.muLut.mu.length === runtime.muLut.fz.length
|
|
611
|
+
&& runtime.muLut.mu.every((value) => approximately(value, 1.1))
|
|
612
|
+
&& approximately(runtime.muLut.slideRatio, 0.85)
|
|
613
|
+
&& equalJson(runtime.loadSensitivity, { frictionCoef: 1, slidingFrictionCoef: 0.85,
|
|
614
|
+
noLoadCoef: 1.1, fullLoadCoef: 1.1, loadSensitivitySlope: 0 });
|
|
615
|
+
const validTyres = record(tyres) && tyres.status === 'unsupported'
|
|
616
|
+
&& record(tyres.axles) && tyreAxle(tyres.axles.front) && tyreAxle(tyres.axles.rear)
|
|
617
|
+
&& record(tyres.variables) && record(tyres.lut) && Array.isArray(tyres.lut.fz)
|
|
618
|
+
&& equalJson(tyres.lut.fz, expectedTyreLoads) && tyres.lut.formula === tyreFormula
|
|
619
|
+
&& Array.isArray(tyres.runtimeBlockers) && tyres.runtimeBlockers.length > 0
|
|
620
|
+
&& typeof tyres.extractionComplete === 'boolean'
|
|
621
|
+
&& tyres.extractionComplete === selectedTyresComplete
|
|
622
|
+
&& Array.isArray(runtimeTyres) && runtimeTyres.length === 2
|
|
623
|
+
&& (tyres.extractionComplete
|
|
624
|
+
? validRuntimeTyre(runtimeTyres[0], 'front') && validRuntimeTyre(runtimeTyres[1], 'rear')
|
|
625
|
+
: runtimeTyres.every(validFallbackTyre));
|
|
626
|
+
const driveline = semantics?.driveline;
|
|
627
|
+
const differentialFields = ['lockTorque', 'activeLockTorque', 'viscousCoef', 'viscousTorque', 'lsdPreload',
|
|
628
|
+
'lsdLockCoef', 'lsdRevLockCoef', 'friction', 'dynamicFriction', 'torqueLossCoef',
|
|
629
|
+
'defaultVirtualInertia', 'speedLimitCoef', 'diffTorqueSplit'];
|
|
630
|
+
const differential = (value) => record(value) && nonempty(value.deviceName)
|
|
631
|
+
&& nonempty(value.partName) && nonempty(value.source) && nonempty(value.type)
|
|
632
|
+
&& differentialFields.every((name) => semanticReceipt(value.fields?.[name])
|
|
633
|
+
&& Object.hasOwn(value.fields[name], 'raw'))
|
|
634
|
+
&& Array.isArray(value.controllers) && value.controllers.every(nonempty)
|
|
635
|
+
&& Array.isArray(value.overlaySources) && value.overlaySources.every(nonempty)
|
|
636
|
+
&& record(value.overlayFieldSources)
|
|
637
|
+
&& Object.values(value.overlayFieldSources).every((sources) => Array.isArray(sources)
|
|
638
|
+
&& sources.length > 0 && sources.every(nonempty))
|
|
639
|
+
&& Array.isArray(value.overlayConflicts)
|
|
640
|
+
&& value.overlayConflicts.every((conflict) => record(conflict) && nonempty(conflict.field)
|
|
641
|
+
&& Array.isArray(conflict.sources) && conflict.sources.length > 1 && conflict.sources.every(nonempty))
|
|
642
|
+
&& (value.type !== 'viscous' || (semanticReceipt(value.fields?.viscousExponent)
|
|
643
|
+
&& Object.hasOwn(value.fields.viscousExponent, 'raw')))
|
|
644
|
+
&& semanticReceipt(value.gearRatio) && record(value.outputPorts)
|
|
645
|
+
&& [value.outputPorts.a, value.outputPorts.b].every((port) => port === 1 || port === 2)
|
|
646
|
+
&& value.outputPorts.a !== value.outputPorts.b;
|
|
647
|
+
const drivenAxles = Array.isArray(driveline?.drivenAxles) ? driveline.drivenAxles : [];
|
|
648
|
+
const differentialEntries = record(driveline?.differentials)
|
|
649
|
+
? Object.entries(driveline.differentials) : [];
|
|
650
|
+
const runtimeDriveline = lowering?.vehiclePackage?.simulation?.land?.driveline;
|
|
651
|
+
const hasRuntimeDiff = (name) => record(runtimeDriveline) && record(runtimeDriveline[name]);
|
|
652
|
+
const expectedDiffCount = drivenAxles.length + (drivenAxles.length === 2 ? 1 : 0);
|
|
653
|
+
const expectedLocations = [...drivenAxles, ...(drivenAxles.length === 2 ? ['centre'] : [])].sort();
|
|
654
|
+
const semanticLocations = differentialEntries.map(([location]) => location).sort();
|
|
655
|
+
const runtimeLocations = ['front', 'rear', 'centre'].filter(hasRuntimeDiff).sort();
|
|
656
|
+
const runtimeFields = {
|
|
657
|
+
lockTorque: 'lockTorque', viscousCoef: 'viscousCoef', viscousTorque: 'viscousTorque',
|
|
658
|
+
lsdPreload: 'lsdPreload', lsdLockCoef: 'lsdLockCoef', lsdRevLockCoef: 'lsdRevLockCoef',
|
|
659
|
+
friction: 'friction', dynamicFriction: 'dynamicFriction',
|
|
660
|
+
};
|
|
661
|
+
const runtimeCriticalDifferentialFields = new Set([
|
|
662
|
+
'type', 'name', 'inputName', 'inputIndex', 'diffType', 'gearRatio', 'viscousExponent',
|
|
663
|
+
...differentialFields,
|
|
664
|
+
]);
|
|
665
|
+
const axleWheels = { front: ['FL', 'FR'], rear: ['RL', 'RR'] };
|
|
666
|
+
const expectedWheels = drivenAxles.flatMap((axle) => axleWheels[axle]).sort();
|
|
667
|
+
const wheelPathEntries = record(driveline?.wheelPaths) ? Object.entries(driveline.wheelPaths) : [];
|
|
668
|
+
const validWheelPaths = nonempty(driveline?.transmissionDevice)
|
|
669
|
+
&& equalJson(wheelPathEntries.map(([wheel]) => wheel).sort(), expectedWheels)
|
|
670
|
+
&& wheelPathEntries.every(([, path]) => Array.isArray(path) && path.length > 0
|
|
671
|
+
&& path.every(nonempty) && new Set(path).size === path.length
|
|
672
|
+
&& path.at(-1) === driveline.transmissionDevice)
|
|
673
|
+
&& drivenAxles.every((axle) => axleWheels[axle].every((wheel) =>
|
|
674
|
+
driveline.wheelPaths[wheel].includes(driveline.differentials[axle]?.deviceName)))
|
|
675
|
+
&& (drivenAxles.length !== 2 || expectedWheels.every((wheel) =>
|
|
676
|
+
driveline.wheelPaths[wheel].includes(driveline.differentials.centre?.deviceName)))
|
|
677
|
+
&& expectedWheels.every((wheel) => {
|
|
678
|
+
const path = driveline.wheelPaths[wheel];
|
|
679
|
+
const axle = wheel.startsWith('F') ? 'front' : 'rear';
|
|
680
|
+
const axleIndex = path.indexOf(driveline.differentials[axle].deviceName);
|
|
681
|
+
const gearboxIndex = path.indexOf(driveline.transmissionDevice);
|
|
682
|
+
if (drivenAxles.length !== 2) return axleIndex >= 0 && axleIndex < gearboxIndex;
|
|
683
|
+
const centreIndex = path.indexOf(driveline.differentials.centre.deviceName);
|
|
684
|
+
return axleIndex >= 0 && axleIndex < centreIndex && centreIndex < gearboxIndex;
|
|
685
|
+
});
|
|
686
|
+
const gearboxKinds = { manualGearbox: 'manual', sequentialGearbox: 'sequential',
|
|
687
|
+
dctGearbox: 'dct', cvtGearbox: 'cvt', automaticGearbox: 'automatic' };
|
|
688
|
+
const validRuntimeGearbox = record(driveline?.gearbox) && driveline.gearbox.status === 'derived'
|
|
689
|
+
&& nonempty(driveline.gearbox.deviceName) && nonempty(driveline.gearbox.deviceType)
|
|
690
|
+
&& nonempty(driveline.gearbox.partName) && nonempty(driveline.gearbox.source)
|
|
691
|
+
&& gearboxKinds[driveline.gearbox.deviceType] === driveline.gearbox.kind
|
|
692
|
+
&& runtimeDriveline?.gearbox?.kind === driveline.gearbox.kind
|
|
693
|
+
&& driveline.transmissionDevice === driveline.gearbox.deviceName;
|
|
694
|
+
const expectedDriveBias = driveline?.runtimeStatus === 'unsupported' ? 0.5
|
|
695
|
+
: drivenAxles.length === 1 ? (drivenAxles[0] === 'front' ? 1 : 0)
|
|
696
|
+
: runtimeDriveline?.centre?.torqueSplit;
|
|
697
|
+
const validLegacyDriveline = semanticReceipt(driveline?.legacyFinalDrive)
|
|
698
|
+
&& approximately(lowering?.vehiclePackage?.physics?.driveBiasFront?.value, expectedDriveBias)
|
|
699
|
+
&& (driveline.legacyFinalDrive.status === 'unsupported'
|
|
700
|
+
|| approximately(lowering?.vehiclePackage?.physics?.gearbox?.finalDrive?.value,
|
|
701
|
+
driveline.legacyFinalDrive.value));
|
|
702
|
+
const validRuntimeDiff = ([location, source], requireParity) => {
|
|
703
|
+
const runtime = runtimeDriveline?.[location];
|
|
704
|
+
const fields = source.fields;
|
|
705
|
+
const supportedType = ['open', 'locked', 'viscous', 'lsd'].includes(source.type);
|
|
706
|
+
const resolved = [...differentialFields.map((name) => fields[name]), source.gearRatio]
|
|
707
|
+
.every((receipt) => receipt.status !== 'unsupported');
|
|
708
|
+
const activeController = source.controllers.some((name) => name.includes('activeCenterDiff'));
|
|
709
|
+
const runtimeConflict = source.overlayConflicts.some((conflict) =>
|
|
710
|
+
runtimeCriticalDifferentialFields.has(conflict.field));
|
|
711
|
+
const representableLosses = ['torqueLossCoef', 'defaultVirtualInertia', 'speedLimitCoef']
|
|
712
|
+
.every((name) => fields[name].value === 0);
|
|
713
|
+
const typeParametersValid = source.type !== 'locked' || fields.lockTorque.value > 0;
|
|
714
|
+
const viscousRuntimeSafe = source.type !== 'viscous'
|
|
715
|
+
|| (fields.viscousCoef.value > 0 && fields.viscousTorque.value > 0
|
|
716
|
+
&& Number.isFinite(fields.viscousExponent.value));
|
|
717
|
+
const viscousParity = viscousRuntimeSafe
|
|
718
|
+
&& (source.type !== 'viscous'
|
|
719
|
+
|| (fields.viscousExponent.status !== 'unsupported' && fields.viscousExponent.value === 1));
|
|
720
|
+
if (!record(runtime) || !supportedType || !resolved || !typeParametersValid || !viscousRuntimeSafe
|
|
721
|
+
|| runtimeConflict
|
|
722
|
+
|| !(source.gearRatio.value > 0)
|
|
723
|
+
|| !(fields.diffTorqueSplit.value >= 0 && fields.diffTorqueSplit.value <= 1)
|
|
724
|
+
|| runtime.type !== source.type
|
|
725
|
+
|| !approximately(runtime.gearRatio, source.gearRatio.value)) return false;
|
|
726
|
+
if (requireParity && (activeController || !representableLosses
|
|
727
|
+
|| fields.activeLockTorque.value !== 0 || !viscousParity)) return false;
|
|
728
|
+
const sourceSplit = source.fields.diffTorqueSplit.value;
|
|
729
|
+
const expectedSplit = source.outputPorts.a === 1 ? sourceSplit : Math.round((1 - sourceSplit) * 1e8) / 1e8;
|
|
730
|
+
if (!approximately(runtime.torqueSplit, expectedSplit)) return false;
|
|
731
|
+
return Object.entries(runtimeFields).every(([runtimeField, receiptField]) =>
|
|
732
|
+
approximately(runtime[runtimeField], source.fields[receiptField].value));
|
|
733
|
+
};
|
|
734
|
+
const validDriveline = record(driveline) && ['functional', 'unsupported'].includes(driveline.status)
|
|
735
|
+
&& ['functional', 'approximate', 'unsupported'].includes(driveline.runtimeStatus)
|
|
736
|
+
&& drivenAxles.every((name) => ['front', 'rear'].includes(name))
|
|
737
|
+
&& new Set(drivenAxles).size === drivenAxles.length && record(driveline.wheelPaths)
|
|
738
|
+
&& differentialEntries.every(([location, value]) => ['front', 'rear', 'centre'].includes(location)
|
|
739
|
+
&& differential(value))
|
|
740
|
+
&& record(driveline.variables) && Array.isArray(driveline.runtimeBlockers)
|
|
741
|
+
&& (explicitUnsupported(driveline.gearbox) || validRuntimeGearbox)
|
|
742
|
+
&& validLegacyDriveline
|
|
743
|
+
&& (driveline.status === 'functional') === (driveline.runtimeBlockers.length === 0)
|
|
744
|
+
&& (driveline.status === 'functional' ? driveline.runtimeStatus === 'functional'
|
|
745
|
+
: driveline.runtimeStatus !== 'functional')
|
|
746
|
+
&& (driveline.runtimeStatus !== 'unsupported'
|
|
747
|
+
? differentialEntries.length === expectedDiffCount
|
|
748
|
+
&& equalJson(semanticLocations, expectedLocations)
|
|
749
|
+
&& equalJson(runtimeLocations, expectedLocations)
|
|
750
|
+
&& validRuntimeGearbox
|
|
751
|
+
&& validWheelPaths
|
|
752
|
+
&& differentialEntries.every((entry) => validRuntimeDiff(entry, driveline.status === 'functional'))
|
|
753
|
+
: !hasRuntimeDiff('front') && !hasRuntimeDiff('rear') && !hasRuntimeDiff('centre'));
|
|
562
754
|
const sourceExtractionComplete = suspension?.axles?.front?.status === 'resolved'
|
|
563
755
|
&& suspension?.axles?.rear?.status === 'resolved'
|
|
564
756
|
&& suspension?.antiRoll?.front?.torsionSpring?.status !== 'unsupported'
|
|
@@ -601,7 +793,32 @@ function assertValidFunctionalLowering(lowering, boundary) {
|
|
|
601
793
|
requiredSemanticTargets.set(`sourceSemantics.suspension.antiRoll.${axle}.torsionSpring`,
|
|
602
794
|
expectedEvidence(suspension.antiRoll[axle].torsionSpring, suspension.antiRoll[axle].source));
|
|
603
795
|
}
|
|
796
|
+
const tyre = tyres?.axles?.[axle];
|
|
797
|
+
if (record(tyre) && nonempty(tyre.partName)) {
|
|
798
|
+
for (const field of tyreFields) {
|
|
799
|
+
requiredSemanticTargets.set(`sourceSemantics.wheelsTyres.axles.${axle}.${field}`,
|
|
800
|
+
expectedEvidence(tyre[field], tyre.source));
|
|
801
|
+
}
|
|
802
|
+
}
|
|
604
803
|
}
|
|
804
|
+
if (record(driveline?.gearbox) && driveline.gearbox.status === 'derived') {
|
|
805
|
+
requiredSemanticTargets.set('simulation.land.driveline.gearbox.kind',
|
|
806
|
+
{ disposition: 'source-literal', source: driveline.gearbox.source });
|
|
807
|
+
}
|
|
808
|
+
for (const [location, row] of differentialEntries) {
|
|
809
|
+
requiredSemanticTargets.set(`sourceSemantics.driveline.differentials.${location}.type`, {
|
|
810
|
+
disposition: ['open', 'locked', 'viscous', 'lsd'].includes(row.type) ? 'source-literal' : 'unsupported',
|
|
811
|
+
source: row.source,
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
requiredSemanticTargets.set('simulation.land.driveline.differentials', {
|
|
815
|
+
disposition: driveline?.status === 'functional' ? 'derived' : 'unsupported',
|
|
816
|
+
source: 'selected JBeam powertrain graph',
|
|
817
|
+
});
|
|
818
|
+
requiredSemanticTargets.set('simulation.land.tyreByAxle', {
|
|
819
|
+
disposition: 'unsupported',
|
|
820
|
+
source: 'selected JBeam pressureWheels',
|
|
821
|
+
});
|
|
605
822
|
const provenanceByTarget = new Map();
|
|
606
823
|
if (Array.isArray(lowering?.provenance)) {
|
|
607
824
|
for (const row of lowering.provenance) {
|
|
@@ -643,14 +860,16 @@ function assertValidFunctionalLowering(lowering, boundary) {
|
|
|
643
860
|
&& record(suspensionCounts)
|
|
644
861
|
&& requiredSuspensionCounts.every((name) => Number.isInteger(suspensionCounts[name])
|
|
645
862
|
&& suspensionCounts[name] >= 0 && suspensionCounts[name] === expectedSuspensionCounts[name])
|
|
646
|
-
&&
|
|
647
|
-
&&
|
|
863
|
+
&& validTyres && Array.isArray(semantics.wheelsTyres.selectedParts)
|
|
864
|
+
&& validDriveline
|
|
648
865
|
&& Array.isArray(semantics.electrics)
|
|
649
866
|
&& Array.isArray(semantics.audioEvents)
|
|
650
867
|
&& Array.isArray(semantics.openableControllers);
|
|
651
868
|
const validCoverage = record(coverage)
|
|
652
869
|
&& record(coverage.functionalDomains)
|
|
653
870
|
&& coverage.functionalDomains.suspension === false
|
|
871
|
+
&& coverage.functionalDomains.wheelsTyres === false
|
|
872
|
+
&& coverage.functionalDomains.differentials === (driveline?.status === 'functional')
|
|
654
873
|
&& record(suspensionProvenance)
|
|
655
874
|
&& requiredProvenanceCounts.every((name) => Number.isInteger(suspensionProvenance[name])
|
|
656
875
|
&& suspensionProvenance[name] >= 0
|
|
@@ -412,6 +412,7 @@ LAMP_NODE_SEMANTICS = {
|
|
|
412
412
|
|
|
413
413
|
TRANSMISSION_DEVICE_KINDS = {
|
|
414
414
|
"manualgearbox": "manual",
|
|
415
|
+
"sequentialgearbox": "sequential",
|
|
415
416
|
"dctgearbox": "dct",
|
|
416
417
|
"cvtgearbox": "cvt",
|
|
417
418
|
"automaticgearbox": "automatic",
|
|
@@ -419,6 +420,7 @@ TRANSMISSION_DEVICE_KINDS = {
|
|
|
419
420
|
|
|
420
421
|
TRANSMISSION_INFO_KINDS = {
|
|
421
422
|
"manual": "manual",
|
|
423
|
+
"sequential": "sequential",
|
|
422
424
|
"dct": "dct",
|
|
423
425
|
"cvt": "cvt",
|
|
424
426
|
"automatic": "automatic",
|
|
@@ -433,17 +435,68 @@ def lamp_beam(light: str) -> dict[str, float] | None:
|
|
|
433
435
|
"penumbra": .3, "intensity": 1}
|
|
434
436
|
|
|
435
437
|
|
|
436
|
-
def
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
438
|
+
def selected_powertrain_devices(selected: Iterable[tuple[str, str, dict[str, Any], Path]],
|
|
439
|
+
label_source: Callable[[Path], str]) -> list[dict[str, Any]]:
|
|
440
|
+
selected_parts = list(selected)
|
|
441
|
+
base_devices: list[tuple[str, str, Path, list[str], dict[str, Any]]] = []
|
|
442
|
+
for slot_name, part_name, body, path in selected_parts:
|
|
443
|
+
controllers = sorted(str(row["fileName"]) for row in decode_jbeam_table(body.get("controller"))
|
|
444
|
+
if isinstance(row.get("fileName"), str))
|
|
442
445
|
for row in decode_jbeam_table(body.get("powertrain")):
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
446
|
+
name = row.get("name")
|
|
447
|
+
if not isinstance(name, str) or not name:
|
|
448
|
+
continue
|
|
449
|
+
base_devices.append((slot_name, part_name, path, controllers, row))
|
|
450
|
+
device_names = {row[4]["name"] for row in base_devices}
|
|
451
|
+
overlay_index: dict[str, list[tuple[dict[str, Any], str]]] = {}
|
|
452
|
+
overlay_count = 0
|
|
453
|
+
for _, part_name, body, path in selected_parts:
|
|
454
|
+
for name, options in body.items():
|
|
455
|
+
if name not in device_names or not isinstance(options, dict):
|
|
456
|
+
continue
|
|
457
|
+
overlay_count += 1
|
|
458
|
+
if overlay_count > MAX_INDEX_PARTS:
|
|
459
|
+
fail(f"selected powertrain device overlays exceed {MAX_INDEX_PARTS}")
|
|
460
|
+
overlay_index.setdefault(name, []).append(
|
|
461
|
+
(options, f"{label_source(path)}#{part_name}.{name}"))
|
|
462
|
+
|
|
463
|
+
devices: list[dict[str, Any]] = []
|
|
464
|
+
for slot_name, part_name, path, controllers, row in base_devices:
|
|
465
|
+
name = row["name"]
|
|
466
|
+
merged = dict(row)
|
|
467
|
+
overlay_sources: list[str] = []
|
|
468
|
+
overlay_fields: dict[str, list[tuple[Any, str]]] = {}
|
|
469
|
+
for options, overlay_source in overlay_index.get(name, []):
|
|
470
|
+
overlay_sources.append(overlay_source)
|
|
471
|
+
for field, value in options.items():
|
|
472
|
+
overlay_fields.setdefault(field, []).append((value, overlay_source))
|
|
473
|
+
overlay_conflicts: list[dict[str, Any]] = []
|
|
474
|
+
for field, values in overlay_fields.items():
|
|
475
|
+
unique_values = {json.dumps(value, sort_keys=True, separators=(",", ":")) for value, _ in values}
|
|
476
|
+
if len(unique_values) == 1:
|
|
477
|
+
merged[field] = values[0][0]
|
|
478
|
+
else:
|
|
479
|
+
sources = [source for _, source in values]
|
|
480
|
+
merged[field] = {"ambiguousOverlaySources": sources}
|
|
481
|
+
overlay_conflicts.append({"field": field, "sources": sources})
|
|
482
|
+
devices.append({**merged, "partName": part_name, "slotType": slot_name,
|
|
483
|
+
"source": f"{label_source(path)}#{part_name}.powertrain",
|
|
484
|
+
"overlaySources": sorted(set(overlay_sources)),
|
|
485
|
+
"overlayFieldSources": {field: sorted({source for _, source in values})
|
|
486
|
+
for field, values in sorted(overlay_fields.items())},
|
|
487
|
+
"overlayConflicts": overlay_conflicts, "controllers": controllers})
|
|
488
|
+
return devices
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def selected_transmission_kind(devices: Iterable[dict[str, Any]],
|
|
492
|
+
config_info: dict[str, Any]) -> tuple[str | None, str, str]:
|
|
493
|
+
"""Return a kind only when the selected, fingerprinted source is unambiguous."""
|
|
494
|
+
evidence: list[tuple[str, str, str, str]] = []
|
|
495
|
+
for row in devices:
|
|
496
|
+
device_type = str(row.get("type", ""))
|
|
497
|
+
kind = TRANSMISSION_DEVICE_KINDS.get(device_type.lower())
|
|
498
|
+
if kind is not None:
|
|
499
|
+
evidence.append((kind, row["partName"], row["source"], device_type))
|
|
447
500
|
kinds = {row[0] for row in evidence}
|
|
448
501
|
info_value = str(config_info.get("Transmission", "")).strip()
|
|
449
502
|
info_kind = TRANSMISSION_INFO_KINDS.get(info_value.lower())
|
|
@@ -451,16 +504,16 @@ def selected_transmission_kind(selected: Iterable[tuple[str, str, dict[str, Any]
|
|
|
451
504
|
detail = ", ".join(sorted(f"{part}:{device}" for _, part, _, device in evidence))
|
|
452
505
|
return None, "selected JBeam#powertrain.type", f"Ambiguous selected transmission devices: {detail}."
|
|
453
506
|
if evidence:
|
|
454
|
-
kind, part_name,
|
|
507
|
+
kind, part_name, source, device_type = evidence[0]
|
|
455
508
|
if info_kind is not None and info_kind != kind:
|
|
456
|
-
return None,
|
|
509
|
+
return None, source, \
|
|
457
510
|
f"Selected {part_name} declares {device_type}, but configuration info declares {info_value}."
|
|
458
|
-
return kind,
|
|
511
|
+
return kind, source, f"Selected {part_name} powertrain device type is {device_type}."
|
|
459
512
|
if info_kind is not None:
|
|
460
513
|
return info_kind, "selected configuration info#Transmission", \
|
|
461
514
|
f"No selected JBeam transmission device row was available; exact configuration info declares {info_value}."
|
|
462
515
|
return None, "selected JBeam#powertrain.type", \
|
|
463
|
-
"No recognized manualGearbox, dctGearbox, cvtGearbox, or automaticGearbox device was selected."
|
|
516
|
+
"No recognized manualGearbox, sequentialGearbox, dctGearbox, cvtGearbox, or automaticGearbox device was selected."
|
|
464
517
|
|
|
465
518
|
|
|
466
519
|
def key_multimap(parts: Iterable[tuple[str, Any]], max_entries: int = 1_000_000,
|
|
@@ -531,6 +584,7 @@ def sourced(value: Any, unit: str, source: str | None = None, note: str | None =
|
|
|
531
584
|
BRAKE_AXLE_PATTERN = re.compile(r"(?:^|_)brakes?_?([FR])(?:_|$)", re.I)
|
|
532
585
|
SUSPENSION_AXLE_PATTERN = re.compile(r"(?:^|_)(?:strut|coilover)_?([FR])(?:_|$)", re.I)
|
|
533
586
|
SWAYBAR_AXLE_PATTERN = re.compile(r"(?:^|_)(?:rally_)?swaybar_?([FR])(?:_|$)", re.I)
|
|
587
|
+
TIRE_AXLE_PATTERN = re.compile(r"(?:^|_)tire_?([FR])(?:_|$)", re.I)
|
|
534
588
|
|
|
535
589
|
|
|
536
590
|
def selected_variable_environment(selected: Iterable[tuple[str, str, dict[str, Any], Path]],
|
|
@@ -751,17 +805,11 @@ def suspension_row_side(row: dict[str, Any]) -> str | None:
|
|
|
751
805
|
|
|
752
806
|
def resolution_receipt(raw: Any, variables: dict[str, Any], unit: str,
|
|
753
807
|
source: str, provenance: list[dict[str, Any]], target: str) -> dict[str, Any]:
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
row["reason"] = result.reason
|
|
760
|
-
detail = result.reason or "Selected value did not resolve."
|
|
761
|
-
else:
|
|
762
|
-
row["value"] = result.value
|
|
763
|
-
detail = (f"Selected literal {result.value:g} {unit}." if result.status == "literal" else
|
|
764
|
-
f"Resolved {raw!r} to {result.value:g} {unit} from selected variables.")
|
|
808
|
+
row, _ = numeric_receipt(raw, variables, unit)
|
|
809
|
+
disposition = "source-literal" if row["status"] == "literal" else row["status"]
|
|
810
|
+
detail = row.get("reason") or (f"Selected literal {row['value']:g} {unit}."
|
|
811
|
+
if row["status"] == "literal" else
|
|
812
|
+
f"Resolved {raw!r} to {row['value']:g} {unit} from selected variables.")
|
|
765
813
|
provenance.append({"target": target, "source": source,
|
|
766
814
|
"disposition": disposition, "detail": detail})
|
|
767
815
|
return row
|
|
@@ -1037,14 +1085,458 @@ def lower_selected_suspension(selected: list[tuple[str, str, dict[str, Any], Pat
|
|
|
1037
1085
|
return suspension, semantics
|
|
1038
1086
|
|
|
1039
1087
|
|
|
1040
|
-
def diff(kind: str, ratio: float, preload: float, lock: float
|
|
1041
|
-
|
|
1042
|
-
|
|
1088
|
+
def diff(kind: str, ratio: float, *, preload: float = 0, lock: float = 0, reverse: float = 0,
|
|
1089
|
+
torque_split: float = .5, friction: float = 0, dynamic: float = 0,
|
|
1090
|
+
lock_torque: float = 0, viscous_coef: float = 0,
|
|
1091
|
+
viscous_torque: float = 0) -> dict[str, Any]:
|
|
1043
1092
|
return {"type": kind, "torqueSplit": torque_split, "gearRatio": ratio, "lockTorque": lock_torque,
|
|
1044
1093
|
"viscousCoef": viscous_coef, "viscousTorque": viscous_torque, "lsdPreload": preload,
|
|
1045
1094
|
"lsdLockCoef": lock, "lsdRevLockCoef": reverse, "friction": friction, "dynamicFriction": dynamic}
|
|
1046
1095
|
|
|
1047
1096
|
|
|
1097
|
+
def numeric_receipt(raw: Any, variables: dict[str, Any], unit: str,
|
|
1098
|
+
minimum: float | None = None, positive: bool = False) -> tuple[dict[str, Any], bool]:
|
|
1099
|
+
result = numeric_resolution(raw, variables)
|
|
1100
|
+
row: dict[str, Any] = {"raw": raw, "status": result.status, "unit": unit,
|
|
1101
|
+
"variables": list(result.variables)}
|
|
1102
|
+
valid = result.status != "unsupported"
|
|
1103
|
+
if valid:
|
|
1104
|
+
value = float(result.value)
|
|
1105
|
+
row["value"] = value
|
|
1106
|
+
if (positive and value <= 0) or (minimum is not None and value < minimum):
|
|
1107
|
+
row["status"] = "unsupported"
|
|
1108
|
+
row["reason"] = "resolved value is outside the admitted runtime range"
|
|
1109
|
+
valid = False
|
|
1110
|
+
else:
|
|
1111
|
+
row["reason"] = result.reason
|
|
1112
|
+
return row, valid
|
|
1113
|
+
|
|
1114
|
+
|
|
1115
|
+
MU_LUT_POINTS = 24
|
|
1116
|
+
MU_LUT_FZ = [round(math.exp(math.log(100) + ((math.log(40000) - math.log(100)) * index)
|
|
1117
|
+
/ (MU_LUT_POINTS - 1)), 9) for index in range(MU_LUT_POINTS)]
|
|
1118
|
+
TYRE_SHAPE_PRIOR = {"alphaPeakRad": .139626, "cxMult": 1.05, "sigmaX": .048,
|
|
1119
|
+
"sigmaY": .08, "camberGain": .15, "dxOverDy": 1, "sFall": .3,
|
|
1120
|
+
"rollingResistance": .013, "angularInertia": 1.3}
|
|
1121
|
+
|
|
1122
|
+
|
|
1123
|
+
def lower_selected_tyres(selected: list[tuple[str, str, dict[str, Any], Path]],
|
|
1124
|
+
variables: dict[str, Any], variable_receipts: dict[str, dict[str, Any]],
|
|
1125
|
+
label_source: Callable[[Path], str],
|
|
1126
|
+
provenance: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], dict[str, Any], bool]:
|
|
1127
|
+
"""Lower one exact selected pressure-wheel tire Package per axle.
|
|
1128
|
+
|
|
1129
|
+
BeamNG supplies load-sensitivity coefficients while HELIX consumes its
|
|
1130
|
+
engine-wide exponential kernel as a compact LUT. Sampling uses the same
|
|
1131
|
+
fixed 24-knot logarithmic grid as the engine; it is not a per-vehicle fit.
|
|
1132
|
+
"""
|
|
1133
|
+
candidates: dict[str, list[dict[str, Any]]] = {"front": [], "rear": []}
|
|
1134
|
+
for slot_name, part_name, body, path in selected:
|
|
1135
|
+
options = pressure_wheel_options(body)
|
|
1136
|
+
if not any(key in options for key in ("pressurePSI", "frictionCoef", "noLoadCoef")):
|
|
1137
|
+
continue
|
|
1138
|
+
axle = selected_part_axle(TIRE_AXLE_PATTERN, slot_name, part_name, body)
|
|
1139
|
+
if axle is not None:
|
|
1140
|
+
candidates[axle].append({"slotType": slot_name, "partName": part_name,
|
|
1141
|
+
"source": f"{label_source(path)}#{part_name}.pressureWheels",
|
|
1142
|
+
"options": options})
|
|
1143
|
+
|
|
1144
|
+
field_specs = {
|
|
1145
|
+
"pressurePSI": ("psi", 0.0, False), "radius": ("m", None, True),
|
|
1146
|
+
"tireWidth": ("m", None, True), "frictionCoef": ("ratio", None, True),
|
|
1147
|
+
"slidingFrictionCoef": ("ratio", None, True), "noLoadCoef": ("ratio", None, True),
|
|
1148
|
+
"fullLoadCoef": ("ratio", None, True), "loadSensitivitySlope": ("1/N", 0.0, False),
|
|
1149
|
+
}
|
|
1150
|
+
fz = MU_LUT_FZ
|
|
1151
|
+
axles: dict[str, Any] = {}
|
|
1152
|
+
runtime: list[dict[str, Any]] = []
|
|
1153
|
+
blockers: list[str] = []
|
|
1154
|
+
used_variables: set[str] = set()
|
|
1155
|
+
for axle in ("front", "rear"):
|
|
1156
|
+
rows = candidates[axle]
|
|
1157
|
+
if len(rows) != 1:
|
|
1158
|
+
reason = f"Expected one selected {axle} tire part; found {len(rows)}."
|
|
1159
|
+
blockers.append(reason)
|
|
1160
|
+
axles[axle] = {"status": "unsupported", "reason": reason,
|
|
1161
|
+
"candidates": [{key: row[key] for key in ("slotType", "partName", "source")}
|
|
1162
|
+
for row in rows]}
|
|
1163
|
+
continue
|
|
1164
|
+
candidate = rows[0]
|
|
1165
|
+
receipts: dict[str, Any] = {}
|
|
1166
|
+
complete = True
|
|
1167
|
+
for field, (unit, minimum, positive) in field_specs.items():
|
|
1168
|
+
receipt, valid = numeric_receipt(candidate["options"].get(field), variables, unit,
|
|
1169
|
+
minimum=minimum, positive=positive)
|
|
1170
|
+
receipts[field] = receipt
|
|
1171
|
+
complete = complete and valid
|
|
1172
|
+
used_variables.update(receipt["variables"])
|
|
1173
|
+
disposition = "source-literal" if receipt["status"] == "literal" else receipt["status"]
|
|
1174
|
+
provenance.append({"target": f"sourceSemantics.wheelsTyres.axles.{axle}.{field}",
|
|
1175
|
+
"source": candidate["source"], "disposition": disposition,
|
|
1176
|
+
"detail": receipt.get("reason") or
|
|
1177
|
+
f"Selected {field} resolved to {receipt.get('value'):g} {unit}."})
|
|
1178
|
+
axle_row = {key: candidate[key] for key in ("slotType", "partName", "source")}
|
|
1179
|
+
axle_row.update(receipts)
|
|
1180
|
+
axle_row["status"] = "functional" if complete else "unsupported"
|
|
1181
|
+
axles[axle] = axle_row
|
|
1182
|
+
if not complete:
|
|
1183
|
+
blockers.append(f"Selected {axle} tire contains unresolved or invalid numeric semantics.")
|
|
1184
|
+
continue
|
|
1185
|
+
values = {field: float(receipt["value"]) for field, receipt in receipts.items()}
|
|
1186
|
+
slide_ratio = values["slidingFrictionCoef"] / values["frictionCoef"]
|
|
1187
|
+
if not 0 < slide_ratio <= 1:
|
|
1188
|
+
receipts["slidingFrictionCoef"]["status"] = "unsupported"
|
|
1189
|
+
receipts["slidingFrictionCoef"]["reason"] = "HELIX muLut.slideRatio must be in (0, 1]."
|
|
1190
|
+
axle_row["status"] = "unsupported"
|
|
1191
|
+
for receipt_provenance in reversed(provenance):
|
|
1192
|
+
if receipt_provenance["target"] == \
|
|
1193
|
+
f"sourceSemantics.wheelsTyres.axles.{axle}.slidingFrictionCoef":
|
|
1194
|
+
receipt_provenance["disposition"] = "unsupported"
|
|
1195
|
+
receipt_provenance["detail"] = receipts["slidingFrictionCoef"]["reason"]
|
|
1196
|
+
break
|
|
1197
|
+
blockers.append(f"Selected {axle} tire sliding/friction ratio is outside (0, 1].")
|
|
1198
|
+
continue
|
|
1199
|
+
mu = [round(values["frictionCoef"] * (values["fullLoadCoef"]
|
|
1200
|
+
+ (values["noLoadCoef"] - values["fullLoadCoef"])
|
|
1201
|
+
* math.exp(-values["loadSensitivitySlope"] * load)), 9) for load in fz]
|
|
1202
|
+
runtime.append({
|
|
1203
|
+
**TYRE_SHAPE_PRIOR,
|
|
1204
|
+
"loadSensitivity": {key: values[key] for key in ("frictionCoef", "slidingFrictionCoef",
|
|
1205
|
+
"noLoadCoef", "fullLoadCoef", "loadSensitivitySlope")},
|
|
1206
|
+
"muLut": {"fz": fz, "mu": mu,
|
|
1207
|
+
"slideRatio": round(slide_ratio, 6)},
|
|
1208
|
+
"radius": values["radius"],
|
|
1209
|
+
"calibration": "prior",
|
|
1210
|
+
})
|
|
1211
|
+
|
|
1212
|
+
extraction_complete = not blockers and len(runtime) == 2
|
|
1213
|
+
if extraction_complete:
|
|
1214
|
+
blockers.extend([
|
|
1215
|
+
"Selected pressurePSI is retained but the HELIX tire runtime has no pressure field.",
|
|
1216
|
+
"Selected tireWidth is retained but the HELIX tire runtime has no width field.",
|
|
1217
|
+
])
|
|
1218
|
+
provenance.append({"target": "simulation.land.tyreByAxle",
|
|
1219
|
+
"source": "selected JBeam pressureWheels", "disposition": "unsupported",
|
|
1220
|
+
"detail": "Radius and load sensitivity use the selected source with the engine-wide exponential 24-knot LUT, but pressure and width have no runtime field; shape coefficients remain explicit priors."})
|
|
1221
|
+
else:
|
|
1222
|
+
provenance.append({"target": "simulation.land.tyreByAxle",
|
|
1223
|
+
"source": "selected JBeam pressureWheels", "disposition": "unsupported",
|
|
1224
|
+
"detail": " ".join(blockers)})
|
|
1225
|
+
fallback = {**TYRE_SHAPE_PRIOR,
|
|
1226
|
+
"loadSensitivity": {"frictionCoef": 1, "slidingFrictionCoef": .85,
|
|
1227
|
+
"noLoadCoef": 1.1, "fullLoadCoef": 1.1, "loadSensitivitySlope": 0},
|
|
1228
|
+
"muLut": {"fz": fz, "mu": [1.1] * MU_LUT_POINTS, "slideRatio": .85},
|
|
1229
|
+
"radius": .3466, "calibration": "prior"}
|
|
1230
|
+
runtime = [fallback, dict(fallback)]
|
|
1231
|
+
functional = False
|
|
1232
|
+
semantics = {"status": "unsupported", "extractionComplete": extraction_complete, "axles": axles,
|
|
1233
|
+
"variables": {name: variable_receipts[name] for name in sorted(used_variables)
|
|
1234
|
+
if name in variable_receipts},
|
|
1235
|
+
"lut": {"fz": fz,
|
|
1236
|
+
"formula": "frictionCoef * (fullLoadCoef + (noLoadCoef - fullLoadCoef) * exp(-loadSensitivitySlope * fz))"},
|
|
1237
|
+
"runtimeBlockers": blockers}
|
|
1238
|
+
return runtime, semantics, functional
|
|
1239
|
+
|
|
1240
|
+
|
|
1241
|
+
def lower_selected_driveline(devices: list[dict[str, Any]],
|
|
1242
|
+
variables: dict[str, Any], variable_receipts: dict[str, dict[str, Any]],
|
|
1243
|
+
provenance: list[dict[str, Any]]) -> tuple[dict[str, dict[str, Any]], dict[str, Any], bool, float]:
|
|
1244
|
+
"""Trace selected devices from connected wheels to the gearbox.
|
|
1245
|
+
|
|
1246
|
+
Device names are BeamNG graph identities. Duplicate names, cycles,
|
|
1247
|
+
disconnected halfshafts, unknown differential kinds, and runtime-unsafe
|
|
1248
|
+
parameters keep receipts and suppress every runtime differential. Safe but
|
|
1249
|
+
inexact behavior fields retain an explicitly approximate runtime.
|
|
1250
|
+
"""
|
|
1251
|
+
by_name: dict[str, list[dict[str, Any]]] = {}
|
|
1252
|
+
for row in devices:
|
|
1253
|
+
by_name.setdefault(row["name"], []).append(row)
|
|
1254
|
+
blockers = [f"Duplicate powertrain device name {name}."
|
|
1255
|
+
for name, rows in sorted(by_name.items()) if len(rows) != 1]
|
|
1256
|
+
runtime_safe = not blockers
|
|
1257
|
+
unique = {name: rows[0] for name, rows in by_name.items() if len(rows) == 1}
|
|
1258
|
+
transmission_devices = [row for row in devices
|
|
1259
|
+
if str(row.get("type", "")).lower() in TRANSMISSION_DEVICE_KINDS]
|
|
1260
|
+
gearbox_name = transmission_devices[0]["name"] if len(transmission_devices) == 1 else None
|
|
1261
|
+
gearbox_semantics = ({"status": "derived", "deviceName": transmission_devices[0]["name"],
|
|
1262
|
+
"deviceType": transmission_devices[0]["type"],
|
|
1263
|
+
"kind": TRANSMISSION_DEVICE_KINDS[str(transmission_devices[0]["type"]).lower()],
|
|
1264
|
+
"partName": transmission_devices[0]["partName"],
|
|
1265
|
+
"source": transmission_devices[0]["source"]}
|
|
1266
|
+
if len(transmission_devices) == 1 else
|
|
1267
|
+
{"status": "unsupported",
|
|
1268
|
+
"reason": f"Expected one selected recognized transmission device; found {len(transmission_devices)}."})
|
|
1269
|
+
if len(transmission_devices) != 1:
|
|
1270
|
+
blockers.append(f"Expected one selected recognized transmission device; found {len(transmission_devices)}.")
|
|
1271
|
+
runtime_safe = False
|
|
1272
|
+
|
|
1273
|
+
def trace(start: str) -> list[str] | None:
|
|
1274
|
+
path: list[str] = []
|
|
1275
|
+
seen: set[str] = set()
|
|
1276
|
+
current: str | None = start
|
|
1277
|
+
for _ in range(128):
|
|
1278
|
+
if current is None or current in seen:
|
|
1279
|
+
return None
|
|
1280
|
+
seen.add(current)
|
|
1281
|
+
path.append(current)
|
|
1282
|
+
if current == gearbox_name:
|
|
1283
|
+
return path
|
|
1284
|
+
row = unique.get(current)
|
|
1285
|
+
if row is None:
|
|
1286
|
+
return path
|
|
1287
|
+
upstream = row.get("inputName")
|
|
1288
|
+
current = upstream if isinstance(upstream, str) and upstream else None
|
|
1289
|
+
return None
|
|
1290
|
+
|
|
1291
|
+
wheel_starts: dict[str, list[str]] = {}
|
|
1292
|
+
for row in devices:
|
|
1293
|
+
wheel = row.get("connectedWheel")
|
|
1294
|
+
if isinstance(wheel, str) and wheel in ("FL", "FR", "RL", "RR"):
|
|
1295
|
+
wheel_starts.setdefault(wheel, []).append(row["name"])
|
|
1296
|
+
wheel_paths: dict[str, list[str]] = {}
|
|
1297
|
+
for wheel in ("FL", "FR", "RL", "RR"):
|
|
1298
|
+
starts = wheel_starts.get(wheel, [])
|
|
1299
|
+
traced = trace(starts[0]) if len(starts) == 1 else None
|
|
1300
|
+
if traced is not None and gearbox_name is not None and gearbox_name in traced:
|
|
1301
|
+
wheel_paths[wheel] = traced
|
|
1302
|
+
elif len(starts) > 1:
|
|
1303
|
+
blockers.append(f"Ambiguous connected-wheel endpoint {wheel}; found {len(starts)} devices.")
|
|
1304
|
+
runtime_safe = False
|
|
1305
|
+
|
|
1306
|
+
driven_axles: list[str] = []
|
|
1307
|
+
axle_wheels = {"front": ("FL", "FR"), "rear": ("RL", "RR")}
|
|
1308
|
+
for axle, wheels in axle_wheels.items():
|
|
1309
|
+
present = [wheel in wheel_paths for wheel in wheels]
|
|
1310
|
+
if all(present):
|
|
1311
|
+
driven_axles.append(axle)
|
|
1312
|
+
elif any(present):
|
|
1313
|
+
blockers.append(f"Only one selected {axle} wheel has a complete path to gearbox.")
|
|
1314
|
+
runtime_safe = False
|
|
1315
|
+
if not driven_axles:
|
|
1316
|
+
blockers.append("No selected axle has two complete wheel-to-gearbox paths.")
|
|
1317
|
+
runtime_safe = False
|
|
1318
|
+
|
|
1319
|
+
differential_names = {name for name, row in unique.items() if row.get("type") == "differential"}
|
|
1320
|
+
common_all: set[str] = set()
|
|
1321
|
+
if driven_axles:
|
|
1322
|
+
driven_paths = [set(wheel_paths[wheel]) for axle in driven_axles for wheel in axle_wheels[axle]]
|
|
1323
|
+
common_all = set.intersection(*driven_paths) & differential_names
|
|
1324
|
+
axle_diff_names: dict[str, str] = {}
|
|
1325
|
+
for axle in driven_axles:
|
|
1326
|
+
left, right = axle_wheels[axle]
|
|
1327
|
+
candidates = (set(wheel_paths[left]) & set(wheel_paths[right]) & differential_names)
|
|
1328
|
+
if len(driven_axles) == 2:
|
|
1329
|
+
candidates -= common_all
|
|
1330
|
+
if len(candidates) != 1:
|
|
1331
|
+
blockers.append(f"Expected one selected {axle} axle differential; found {len(candidates)}.")
|
|
1332
|
+
runtime_safe = False
|
|
1333
|
+
else:
|
|
1334
|
+
axle_diff_names[axle] = next(iter(candidates))
|
|
1335
|
+
centre_name: str | None = None
|
|
1336
|
+
if len(driven_axles) == 2:
|
|
1337
|
+
candidates = common_all - set(axle_diff_names.values())
|
|
1338
|
+
if len(candidates) != 1:
|
|
1339
|
+
blockers.append(f"Expected one selected AWD centre differential; found {len(candidates)}.")
|
|
1340
|
+
runtime_safe = False
|
|
1341
|
+
else:
|
|
1342
|
+
centre_name = next(iter(candidates))
|
|
1343
|
+
|
|
1344
|
+
selected_diffs = {axle: unique[name] for axle, name in axle_diff_names.items()}
|
|
1345
|
+
if centre_name is not None:
|
|
1346
|
+
selected_diffs["centre"] = unique[centre_name]
|
|
1347
|
+
|
|
1348
|
+
def branch_port(diff_name: str, wheels: tuple[str, ...]) -> int | None:
|
|
1349
|
+
children: set[str] = set()
|
|
1350
|
+
ports: set[int] = set()
|
|
1351
|
+
for wheel in wheels:
|
|
1352
|
+
path = wheel_paths[wheel]
|
|
1353
|
+
index = path.index(diff_name)
|
|
1354
|
+
if index == 0:
|
|
1355
|
+
return None
|
|
1356
|
+
child_name = path[index - 1]
|
|
1357
|
+
child = unique.get(child_name)
|
|
1358
|
+
port = child.get("inputIndex") if child else None
|
|
1359
|
+
if not isinstance(port, int):
|
|
1360
|
+
return None
|
|
1361
|
+
children.add(child_name)
|
|
1362
|
+
ports.add(port)
|
|
1363
|
+
return next(iter(ports)) if len(children) == 1 and len(ports) == 1 else None
|
|
1364
|
+
|
|
1365
|
+
runtime: dict[str, dict[str, Any]] = {}
|
|
1366
|
+
semantics_diffs: dict[str, Any] = {}
|
|
1367
|
+
used_variables: set[str] = set()
|
|
1368
|
+
field_specs = {
|
|
1369
|
+
"lockTorque": "N.m", "activeLockTorque": "N.m",
|
|
1370
|
+
"viscousCoef": "N.m/(rad/s)", "viscousTorque": "N.m", "lsdPreload": "N.m",
|
|
1371
|
+
"lsdLockCoef": "ratio", "lsdRevLockCoef": "ratio", "friction": "N.m",
|
|
1372
|
+
"dynamicFriction": "N.m/(rad/s)", "torqueLossCoef": "ratio",
|
|
1373
|
+
"defaultVirtualInertia": "kg.m^2", "speedLimitCoef": "ratio",
|
|
1374
|
+
}
|
|
1375
|
+
for location, row in selected_diffs.items():
|
|
1376
|
+
dtype = str(row.get("diffType", ""))
|
|
1377
|
+
kind = dtype.lower()
|
|
1378
|
+
if kind not in ("open", "locked", "viscous", "lsd"):
|
|
1379
|
+
blockers.append(f"Selected {location} differential type {dtype or '<missing>'} is unsupported.")
|
|
1380
|
+
runtime_safe = False
|
|
1381
|
+
fields: dict[str, Any] = {}
|
|
1382
|
+
values: dict[str, float] = {}
|
|
1383
|
+
for field, unit in field_specs.items():
|
|
1384
|
+
raw = row.get(field, 0)
|
|
1385
|
+
receipt, valid = numeric_receipt(raw, variables, unit, minimum=0)
|
|
1386
|
+
if field not in row and valid:
|
|
1387
|
+
receipt["status"] = "defaulted"
|
|
1388
|
+
receipt["reason"] = "BeamNG field absent; HELIX neutral value 0."
|
|
1389
|
+
fields[field] = receipt
|
|
1390
|
+
if valid:
|
|
1391
|
+
values[field] = float(receipt["value"])
|
|
1392
|
+
else:
|
|
1393
|
+
blockers.append(f"Selected {location} differential field {field} is unresolved.")
|
|
1394
|
+
runtime_safe = False
|
|
1395
|
+
used_variables.update(receipt["variables"])
|
|
1396
|
+
split_raw = row.get("diffTorqueSplit", .5)
|
|
1397
|
+
split, split_valid = numeric_receipt(split_raw, variables, "ratio", minimum=0)
|
|
1398
|
+
if "diffTorqueSplit" not in row and split_valid:
|
|
1399
|
+
split["status"] = "defaulted"
|
|
1400
|
+
split["reason"] = "BeamNG field absent; equal HELIX split 0.5."
|
|
1401
|
+
if split_valid and float(split["value"]) > 1:
|
|
1402
|
+
split["status"] = "unsupported"
|
|
1403
|
+
split["reason"] = "torque split must be within [0, 1]"
|
|
1404
|
+
split_valid = False
|
|
1405
|
+
fields["diffTorqueSplit"] = split
|
|
1406
|
+
used_variables.update(split["variables"])
|
|
1407
|
+
if not split_valid:
|
|
1408
|
+
blockers.append(f"Selected {location} differential torque split is unresolved or invalid.")
|
|
1409
|
+
runtime_safe = False
|
|
1410
|
+
if kind == "locked" and values.get("lockTorque", 0) <= 0:
|
|
1411
|
+
blockers.append(f"Selected {location} locked differential has no positive lockTorque.")
|
|
1412
|
+
runtime_safe = False
|
|
1413
|
+
active_controllers = [name for name in row.get("controllers", []) if "activeCenterDiff" in name]
|
|
1414
|
+
if values.get("activeLockTorque", 0) > 0 or active_controllers:
|
|
1415
|
+
blockers.append(f"Selected {location} activeLockTorque/controller cannot be represented by the HELIX differential runtime.")
|
|
1416
|
+
for field in ("torqueLossCoef", "defaultVirtualInertia", "speedLimitCoef"):
|
|
1417
|
+
if values.get(field, 0) > 0:
|
|
1418
|
+
blockers.append(
|
|
1419
|
+
f"Selected {location} differential {field} cannot be represented by the HELIX differential runtime."
|
|
1420
|
+
)
|
|
1421
|
+
if kind == "viscous":
|
|
1422
|
+
exponent_raw = row.get("viscousExponent")
|
|
1423
|
+
exponent, exponent_valid = numeric_receipt(exponent_raw, variables, "ratio", positive=True)
|
|
1424
|
+
if exponent_valid and exponent.get("value") != 1:
|
|
1425
|
+
exponent["status"] = "unsupported"
|
|
1426
|
+
exponent["reason"] = "HELIX differential runtime has no viscousExponent field."
|
|
1427
|
+
blockers.append(f"Selected {location} viscousExponent cannot be represented exactly.")
|
|
1428
|
+
elif not exponent_valid:
|
|
1429
|
+
blockers.append(f"Selected {location} viscousExponent is unresolved.")
|
|
1430
|
+
runtime_safe = False
|
|
1431
|
+
fields["viscousExponent"] = exponent
|
|
1432
|
+
used_variables.update(exponent["variables"])
|
|
1433
|
+
if values.get("viscousCoef", 0) <= 0 or values.get("viscousTorque", 0) <= 0:
|
|
1434
|
+
blockers.append(f"Selected {location} viscous differential parameters must be positive.")
|
|
1435
|
+
runtime_safe = False
|
|
1436
|
+
if kind == "lsd" and any(values.get(field, -1) < 0
|
|
1437
|
+
for field in ("lsdPreload", "lsdLockCoef", "lsdRevLockCoef")):
|
|
1438
|
+
blockers.append(f"Selected {location} LSD parameters are invalid.")
|
|
1439
|
+
runtime_safe = False
|
|
1440
|
+
source = row["source"]
|
|
1441
|
+
semantics_diffs[location] = {"deviceName": row["name"], "partName": row["partName"],
|
|
1442
|
+
"source": source, "type": kind, "fields": fields,
|
|
1443
|
+
"controllers": row.get("controllers", []),
|
|
1444
|
+
"overlaySources": row.get("overlaySources", []),
|
|
1445
|
+
"overlayFieldSources": row.get("overlayFieldSources", {}),
|
|
1446
|
+
"overlayConflicts": row.get("overlayConflicts", [])}
|
|
1447
|
+
provenance.append({"target": f"sourceSemantics.driveline.differentials.{location}.type",
|
|
1448
|
+
"source": source,
|
|
1449
|
+
"disposition": "source-literal" if kind in ("open", "locked", "viscous", "lsd") else "unsupported",
|
|
1450
|
+
"detail": f"Selected connected differential declares diffType {dtype or '<missing>'}."})
|
|
1451
|
+
|
|
1452
|
+
ratio_raw = row.get("gearRatio", 1 if location == "centre" else None)
|
|
1453
|
+
ratio_receipt, ratio_valid = numeric_receipt(ratio_raw, variables, "ratio", positive=True)
|
|
1454
|
+
if location == "centre" and "gearRatio" not in row and ratio_valid:
|
|
1455
|
+
ratio_receipt["status"] = "defaulted"
|
|
1456
|
+
ratio_receipt["reason"] = "BeamNG centre gearRatio absent; neutral ratio 1."
|
|
1457
|
+
semantics_diffs[location]["gearRatio"] = ratio_receipt
|
|
1458
|
+
used_variables.update(ratio_receipt["variables"])
|
|
1459
|
+
ratio = float(ratio_receipt.get("value", 1))
|
|
1460
|
+
if not ratio_valid:
|
|
1461
|
+
blockers.append(f"Selected {location} differential gearRatio is absent or invalid.")
|
|
1462
|
+
runtime_safe = False
|
|
1463
|
+
|
|
1464
|
+
if location == "centre":
|
|
1465
|
+
a_port = branch_port(row["name"], ("FL", "FR"))
|
|
1466
|
+
b_port = branch_port(row["name"], ("RL", "RR"))
|
|
1467
|
+
elif location == "front":
|
|
1468
|
+
a_port = branch_port(row["name"], ("FL",))
|
|
1469
|
+
b_port = branch_port(row["name"], ("FR",))
|
|
1470
|
+
else:
|
|
1471
|
+
a_port = branch_port(row["name"], ("RL",))
|
|
1472
|
+
b_port = branch_port(row["name"], ("RR",))
|
|
1473
|
+
semantics_diffs[location]["outputPorts"] = {"a": a_port, "b": b_port}
|
|
1474
|
+
if {a_port, b_port} != {1, 2}:
|
|
1475
|
+
blockers.append(f"Selected {location} differential output ports are missing, duplicate, or ambiguous.")
|
|
1476
|
+
runtime_safe = False
|
|
1477
|
+
if kind in ("open", "locked", "viscous", "lsd"):
|
|
1478
|
+
source_split = float(split.get("value", .5))
|
|
1479
|
+
runtime_split = source_split if a_port == 1 else round(1 - source_split, 8)
|
|
1480
|
+
runtime[location] = diff(kind, ratio, preload=values.get("lsdPreload", 0),
|
|
1481
|
+
lock=values.get("lsdLockCoef", 0),
|
|
1482
|
+
reverse=values.get("lsdRevLockCoef", 0), torque_split=runtime_split,
|
|
1483
|
+
friction=values.get("friction", 0),
|
|
1484
|
+
dynamic=values.get("dynamicFriction", 0),
|
|
1485
|
+
lock_torque=values.get("lockTorque", 0),
|
|
1486
|
+
viscous_coef=values.get("viscousCoef", 0),
|
|
1487
|
+
viscous_torque=values.get("viscousTorque", 0))
|
|
1488
|
+
|
|
1489
|
+
functional = not blockers and len(selected_diffs) == len(driven_axles) + (1 if len(driven_axles) == 2 else 0)
|
|
1490
|
+
if len(selected_diffs) != len(driven_axles) + (1 if len(driven_axles) == 2 else 0):
|
|
1491
|
+
runtime_safe = False
|
|
1492
|
+
if not runtime_safe:
|
|
1493
|
+
runtime = {}
|
|
1494
|
+
if driven_axles == ["front"]:
|
|
1495
|
+
bias_front = 1.0
|
|
1496
|
+
elif driven_axles == ["rear"]:
|
|
1497
|
+
bias_front = 0.0
|
|
1498
|
+
elif centre_name and isinstance(selected_diffs.get("centre"), dict):
|
|
1499
|
+
centre = semantics_diffs["centre"]
|
|
1500
|
+
centre_split = centre["fields"]["diffTorqueSplit"]
|
|
1501
|
+
if centre_split["status"] != "unsupported":
|
|
1502
|
+
source_split = float(centre_split["value"])
|
|
1503
|
+
bias_front = source_split if centre["outputPorts"]["a"] == 1 else round(1 - source_split, 8)
|
|
1504
|
+
else:
|
|
1505
|
+
bias_front = .5
|
|
1506
|
+
else:
|
|
1507
|
+
bias_front = .5
|
|
1508
|
+
if not runtime:
|
|
1509
|
+
bias_front = .5
|
|
1510
|
+
axle_ratios = [row["gearRatio"] for location, row in semantics_diffs.items()
|
|
1511
|
+
if location in ("front", "rear") and row["gearRatio"]["status"] != "unsupported"]
|
|
1512
|
+
unique_axle_ratios = {float(row["value"]) for row in axle_ratios}
|
|
1513
|
+
if len(axle_ratios) == len(driven_axles) and len(unique_axle_ratios) == 1:
|
|
1514
|
+
legacy_final_drive = {"raw": [row["raw"] for row in axle_ratios], "status": "derived",
|
|
1515
|
+
"unit": "ratio", "variables": sorted({variable for row in axle_ratios
|
|
1516
|
+
for variable in row["variables"]}),
|
|
1517
|
+
"value": next(iter(unique_axle_ratios))}
|
|
1518
|
+
else:
|
|
1519
|
+
legacy_final_drive = {"raw": [row.get("raw") for row in axle_ratios], "status": "unsupported",
|
|
1520
|
+
"unit": "ratio", "variables": sorted({variable for row in axle_ratios
|
|
1521
|
+
for variable in row["variables"]}),
|
|
1522
|
+
"reason": "Driven axle ratios are missing or unequal; one legacy finalDrive cannot represent them."}
|
|
1523
|
+
provenance.append({"target": "simulation.land.driveline.differentials",
|
|
1524
|
+
"source": "selected JBeam powertrain graph",
|
|
1525
|
+
"disposition": "derived" if functional else "unsupported",
|
|
1526
|
+
"detail": "Wheel endpoints traced to gearbox without synthesized axles or centre differential."
|
|
1527
|
+
if functional else " ".join(blockers)})
|
|
1528
|
+
semantics = {"status": "functional" if functional else "unsupported",
|
|
1529
|
+
"runtimeStatus": "functional" if functional else "approximate" if runtime else "unsupported",
|
|
1530
|
+
"gearbox": gearbox_semantics, "transmissionDevice": gearbox_name,
|
|
1531
|
+
"legacyFinalDrive": legacy_final_drive,
|
|
1532
|
+
"drivenAxles": driven_axles, "wheelPaths": wheel_paths,
|
|
1533
|
+
"differentials": semantics_diffs,
|
|
1534
|
+
"variables": {name: variable_receipts[name] for name in sorted(used_variables)
|
|
1535
|
+
if name in variable_receipts},
|
|
1536
|
+
"runtimeBlockers": blockers}
|
|
1537
|
+
return runtime, semantics, functional, bias_front
|
|
1538
|
+
|
|
1539
|
+
|
|
1048
1540
|
def main() -> None:
|
|
1049
1541
|
parser = argparse.ArgumentParser()
|
|
1050
1542
|
parser.add_argument("--source-root", required=True)
|
|
@@ -1357,12 +1849,10 @@ def main() -> None:
|
|
|
1357
1849
|
published_power_rpm = range_midpoint(config_info.get("PowerPeakRPM"), peak_power_rpm)
|
|
1358
1850
|
published_torque_rpm = range_midpoint(config_info.get("TorquePeakRPM"), peak_rpm)
|
|
1359
1851
|
|
|
1360
|
-
power_rows
|
|
1361
|
-
for _, _, body, _ in selected:
|
|
1362
|
-
power_rows.extend(decode_jbeam_table(body.get("powertrain")))
|
|
1852
|
+
power_rows = selected_powertrain_devices(selected, source_label)
|
|
1363
1853
|
engine_device = next((r for r in power_rows if r.get("type") == "combustionEngine"), {})
|
|
1364
1854
|
transmission_kind, transmission_source, transmission_detail = selected_transmission_kind(
|
|
1365
|
-
|
|
1855
|
+
power_rows, config_info)
|
|
1366
1856
|
transmission_kind_sourced = transmission_kind is not None
|
|
1367
1857
|
if transmission_kind_sourced:
|
|
1368
1858
|
prov("simulation.land.driveline.gearbox.kind", transmission_source, "source-literal", transmission_detail)
|
|
@@ -1430,6 +1920,14 @@ def main() -> None:
|
|
|
1430
1920
|
suspension, suspension_semantics = lower_selected_suspension(
|
|
1431
1921
|
selected, selected_variables, selected_variable_receipts, source_label,
|
|
1432
1922
|
chassis_mass, weight_distribution_front, provenance)
|
|
1923
|
+
differential_runtime, driveline_semantics, differentials_functional, drive_bias_front = lower_selected_driveline(
|
|
1924
|
+
power_rows, selected_variables, selected_variable_receipts, provenance)
|
|
1925
|
+
legacy_final_drive = driveline_semantics["legacyFinalDrive"]
|
|
1926
|
+
if legacy_final_drive["status"] != "unsupported":
|
|
1927
|
+
final_drive = float(legacy_final_drive["value"])
|
|
1928
|
+
fd_key = "selected-driveline-final-drive"
|
|
1929
|
+
tyre_by_axle, tyre_semantics, tyres_functional = lower_selected_tyres(
|
|
1930
|
+
selected, selected_variables, selected_variable_receipts, source_label, provenance)
|
|
1433
1931
|
turbo_parts = [(name, body, path) for _, name, body, path in selected if isinstance(body.get("turbocharger"), dict)]
|
|
1434
1932
|
wastegate_psi = None
|
|
1435
1933
|
for _, name, body, _ in selected:
|
|
@@ -1464,36 +1962,14 @@ def main() -> None:
|
|
|
1464
1962
|
prov("physics.induction", f"info_{config_path.stem}.json#Induction Type", "discovered-not-lowered",
|
|
1465
1963
|
"Selected configuration declares forced induction but exact turbo and ECU literals were incomplete.")
|
|
1466
1964
|
|
|
1467
|
-
diffs: dict[str, dict[str, Any]] = {}
|
|
1468
|
-
for row in power_rows:
|
|
1469
|
-
if row.get("type") not in ("differential", "shaft"):
|
|
1470
|
-
continue
|
|
1471
|
-
name = str(row.get("name", "")).lower()
|
|
1472
|
-
key = "centre" if "transfer" in name or "center" in name or "centre" in name else "front" if "_f" in name else "rear" if "_r" in name else ""
|
|
1473
|
-
if not key: continue
|
|
1474
|
-
dtype = str(row.get("diffType", "open")).lower()
|
|
1475
|
-
if dtype not in ("open", "locked", "viscous", "lsd"): dtype = "open"
|
|
1476
|
-
diffs[key] = diff(dtype, final_drive if key != "centre" else float(row.get("gearRatio", 1) or 1),
|
|
1477
|
-
float(row.get("lsdPreload", 0) or 0), float(row.get("lsdLockCoef", 0) or 0),
|
|
1478
|
-
float(row.get("lsdRevLockCoef", 0) or 0),
|
|
1479
|
-
float(row.get("diffTorqueSplit", .5) or .5), float(row.get("friction", 0) or 0),
|
|
1480
|
-
float(row.get("dynamicFriction", 0) or 0), float(row.get("lockTorque", 0) or 0),
|
|
1481
|
-
float(row.get("viscousCoef", 0) or 0), float(row.get("viscousTorque", 0) or 0))
|
|
1482
|
-
# Selected Sunburst transfer case expresses the centre diff as a shaft row.
|
|
1483
|
-
for split in indexed_values(value_index, "diffTorqueSplit"):
|
|
1484
|
-
if isinstance(split, (int, float)):
|
|
1485
|
-
diffs["centre"] = diff("lsd", 1, 2.5, .175, .175, float(split))
|
|
1486
|
-
front = diffs.get("front", diff("lsd", final_drive, 45, .175, .01))
|
|
1487
|
-
rear = diffs.get("rear", diff("lsd", final_drive, 65, .175, .175))
|
|
1488
|
-
centre = diffs.get("centre", diff("lsd", 1, 2.5, .175, .175, .65))
|
|
1489
|
-
|
|
1490
1965
|
# Geometry can be supplied from Blender's authoritative receipt.
|
|
1491
1966
|
geometry: dict[str, Any] = {}
|
|
1492
1967
|
if args.geometry:
|
|
1493
1968
|
document = json.loads(Path(args.geometry).read_text())
|
|
1494
1969
|
geometry = document.get("HELIX_vehicle", document)
|
|
1495
1970
|
sockets = geometry.get("sockets") if isinstance(geometry.get("sockets"), dict) else {}
|
|
1496
|
-
|
|
1971
|
+
selected_tyre_radius = sum(float(row["radius"]) for row in tyre_by_axle) / len(tyre_by_axle)
|
|
1972
|
+
wheel_radius = float(geometry.get("wheelRadius", selected_tyre_radius))
|
|
1497
1973
|
bounds = geometry.get("bounds", {})
|
|
1498
1974
|
extents = bounds.get("size") if isinstance(bounds, dict) else None
|
|
1499
1975
|
if (not isinstance(extents, list) or len(extents) != 3) and isinstance(bounds, dict):
|
|
@@ -1594,12 +2070,6 @@ def main() -> None:
|
|
|
1594
2070
|
audio_slots.append({"slot": slot_name, "buffers": [sample] if sample else [], "consumed": bool(sample),
|
|
1595
2071
|
**({} if sample else {"reason": "No selected source event was mapped to this HELIX slot."})})
|
|
1596
2072
|
|
|
1597
|
-
tyre = {"loadSensitivity": {"frictionCoef": 1, "slidingFrictionCoef": .85, "noLoadCoef": 1.1,
|
|
1598
|
-
"fullLoadCoef": 1.1, "loadSensitivitySlope": 0},
|
|
1599
|
-
"muLut": {"fz": [1000, 3000, 6000, 9000], "mu": [1.1, 1.1, 1.1, 1.1], "slideRatio": .85},
|
|
1600
|
-
"alphaPeakRad": .139626, "cxMult": 1.05, "sigmaX": .048, "sigmaY": .08,
|
|
1601
|
-
"camberGain": .15, "dxOverDy": 1, "sFall": .3, "rollingResistance": .013,
|
|
1602
|
-
"radius": wheel_radius, "angularInertia": 1.3, "calibration": "prior"}
|
|
1603
2073
|
engine_spec = {"torqueCurve": {"points": torque_curve}, "idleRpm": idle, "limiterRpm": limiter,
|
|
1604
2074
|
"revLimiterType": "soft", "inertia": inertia, "friction": friction,
|
|
1605
2075
|
"dynamicFriction": dyn_friction, "engineBrakeTorque": coast,
|
|
@@ -1611,9 +2081,9 @@ def main() -> None:
|
|
|
1611
2081
|
"upRpm": max(idle + 1, limiter - 200), "downRpm": 2800}}
|
|
1612
2082
|
land = {"version": 1, "driveline": {"engine": engine_spec,
|
|
1613
2083
|
"clutch": {"inertia": .1, "capacity": peak_torque * 1.6, "freePlay": .2, "launchStartRpm": 2000, "launchTargetRpm": 2500},
|
|
1614
|
-
"gearbox": gearbox_spec,
|
|
2084
|
+
"gearbox": gearbox_spec, **differential_runtime},
|
|
1615
2085
|
"abs": {"targetSlip": .12, "releaseRate": 12, "applyRate": 5, "minSpeed": 2.5},
|
|
1616
|
-
"tyreByAxle":
|
|
2086
|
+
"tyreByAxle": tyre_by_axle}
|
|
1617
2087
|
|
|
1618
2088
|
physics = {
|
|
1619
2089
|
"chassis": {"mass": sourced(float(config_info["Weight"]), "kg", source_ref,
|
|
@@ -1640,7 +2110,10 @@ def main() -> None:
|
|
|
1640
2110
|
"gearbox": {"ratios": sourced(ratios, "ratio", source_ref if ratios_sourced else None), "reverseRatio": sourced(reverse, "ratio", source_ref if reverse_key != "HELIX-default" else None),
|
|
1641
2111
|
"finalDrive": sourced(final_drive, "ratio", source_ref if fd_key != "HELIX-default" else None), "shiftTime": sourced(shift_delay, "s", source_ref if shift_key != "HELIX-default" else None),
|
|
1642
2112
|
"autoUpRpm": sourced(max(idle + 1, limiter - 200), "rpm", note="HELIX automatic shift prior; ignored when the selected transmission is manual."), "autoDownRpm": sourced(2800, "rpm", note="HELIX automatic shift prior; ignored when the selected transmission is manual.")},
|
|
1643
|
-
"driveBiasFront": sourced(
|
|
2113
|
+
"driveBiasFront": sourced(drive_bias_front, "ratio", source_ref if differentials_functional else None,
|
|
2114
|
+
"Derived from selected driven-axle topology and centre rear torque split."
|
|
2115
|
+
if differentials_functional else
|
|
2116
|
+
"Neutral fallback because selected driveline topology is unsupported."),
|
|
1644
2117
|
"brakes": brakes,
|
|
1645
2118
|
"aero": {"dragCoefficient": sourced(.32, "ratio", note="Explicit sports-sedan prior."), "frontalArea": sourced(2.2, "m2", note="width*height*shape factor prior."),
|
|
1646
2119
|
"airDensity": sourced(1.225, "kg/m3", note="ISA sea-level standard."), "downforceCoefficient": sourced(0, "ratio", note="No selected literal aerodynamic coefficient.")},
|
|
@@ -1652,14 +2125,15 @@ def main() -> None:
|
|
|
1652
2125
|
"rackTime": sourced(.8, "s", note="Derived from selected steering in/out rate."), "speedSensitivity": sourced([[0, 1], [50, .45]], "m/s,ratio", note="HELIX steering assist prior.")},
|
|
1653
2126
|
"linearDamping": sourced(.02, "ratio", note="HELIX solver stability prior."), "angularDamping": sourced(.08, "ratio", note="HELIX solver stability prior.")}
|
|
1654
2127
|
|
|
2128
|
+
# Bridge materializes one canonical GLB today. Do not advertise planned
|
|
2129
|
+
# optimization targets as selectable artifacts until the optimizer has
|
|
2130
|
+
# actually sealed them into the package.
|
|
1655
2131
|
renditions = [{"id": "source", "kind": "source", "triangleBudget": 500000, "textureMaxDimension": 4096}]
|
|
1656
|
-
for rid, budget, texture in [("mobile-low", 35000, 512), ("balanced", 90000, 1024), ("desktop-high", 220000, 2048)]:
|
|
1657
|
-
renditions.append({"id": rid, "kind": "derived", "sourceRenditionId": "source", "triangleBudget": budget, "textureMaxDimension": texture})
|
|
1658
2132
|
tiers = {
|
|
1659
|
-
"minimal": {"renditionId":"
|
|
1660
|
-
"low": {"renditionId":"
|
|
1661
|
-
"medium": {"renditionId":"
|
|
1662
|
-
"high": {"renditionId":"
|
|
2133
|
+
"minimal": {"renditionId":"source","interior":"hidden","materialLayers":"base","lightBeams":False,"physicalPanels":False,"detachableParts":False},
|
|
2134
|
+
"low": {"renditionId":"source","interior":"simplified","materialLayers":"base","lightBeams":False,"physicalPanels":False,"detachableParts":False},
|
|
2135
|
+
"medium": {"renditionId":"source","interior":"simplified","materialLayers":"dynamic","lightBeams":True,"physicalPanels":True,"detachableParts":False},
|
|
2136
|
+
"high": {"renditionId":"source","interior":"full","materialLayers":"full","lightBeams":True,"physicalPanels":True,"detachableParts":False},
|
|
1663
2137
|
"ultra": {"renditionId":"source","interior":"full","materialLayers":"full","lightBeams":True,"physicalPanels":True,"detachableParts":False},
|
|
1664
2138
|
}
|
|
1665
2139
|
package_id = f"beamng-{slug(candidate)}-{slug(config_path.stem)}"
|
|
@@ -1741,8 +2215,9 @@ def main() -> None:
|
|
|
1741
2215
|
and row["brakeTorque"].get("raw") is not None}),
|
|
1742
2216
|
**brake_semantics},
|
|
1743
2217
|
"suspension": suspension_semantics,
|
|
2218
|
+
"driveline": driveline_semantics,
|
|
1744
2219
|
"wheelsTyres": {"selectedParts": sorted(name for name in selected_names if any(token in name.lower() for token in ("wheel", "rim", "tire", "tyre"))),
|
|
1745
|
-
|
|
2220
|
+
**tyre_semantics},
|
|
1746
2221
|
"electrics": sorted({str(value) for value in indexed_values(value_index, "electricsName") + indexed_values(value_index, "$electric")}),
|
|
1747
2222
|
"audioEvents": sorted({str(value) for value in indexed_values(value_index, "sampleName") + indexed_values(value_index, "deformSound")}),
|
|
1748
2223
|
"openableControllers": [name for name in controllers if name == "advancedCouplerControl"],
|
|
@@ -1762,7 +2237,6 @@ def main() -> None:
|
|
|
1762
2237
|
prov("runtime.controllers", "selected JBeam controller tables", "unsupported", f"{len(controllers)} controller types retained in capability ledger.")
|
|
1763
2238
|
prov("runtime.softbody", "selected JBeam nodes/beams/triangles", "out-of-scope", "Not emitted into runtime contract.")
|
|
1764
2239
|
prov("runtime.fluids", "selected JBeam energy/thermal sections", "out-of-scope", "Not emitted into runtime contract.")
|
|
1765
|
-
prov("simulation.land.tyreByAxle", "selected BeamNG wheel/tyre identities", "defaulted", "Compact common corpus lacks literal tyre model rows.")
|
|
1766
2240
|
prov("mesh.visual.tiers", "HELIX engine fidelity contract", "defaulted", "Presentation-only policy; simulation and identity are invariant.")
|
|
1767
2241
|
|
|
1768
2242
|
provenance.sort(key=lambda row: (row["target"], row["source"], row["disposition"], row["detail"]))
|
|
@@ -1775,11 +2249,10 @@ def main() -> None:
|
|
|
1775
2249
|
"geometrySockets": bool(sockets),
|
|
1776
2250
|
"engine": bool(engine_parts and len(torque) >= 2 and (not turbo_declared or induction is not None)),
|
|
1777
2251
|
"gearbox": ratios_sourced and transmission_kind_sourced,
|
|
1778
|
-
"differentials":
|
|
2252
|
+
"differentials": differentials_functional,
|
|
1779
2253
|
"brakes": brakes_functional,
|
|
1780
2254
|
"suspension": False,
|
|
1781
|
-
"wheelsTyres":
|
|
1782
|
-
if isinstance(value, (int, float))),
|
|
2255
|
+
"wheelsTyres": tyres_functional,
|
|
1783
2256
|
"seatsCameras": any(str(name).startswith("seat") for name in sockets),
|
|
1784
2257
|
"lightsElectrics": light_evidence,
|
|
1785
2258
|
"audio": bool(engine_sample or exhaust_sample),
|