diff --git a/lib/bus-solver/TinyHyperGraphBusSolver.ts b/lib/bus-solver/TinyHyperGraphBusSolver.ts index 463034b..18c9994 100644 --- a/lib/bus-solver/TinyHyperGraphBusSolver.ts +++ b/lib/bus-solver/TinyHyperGraphBusSolver.ts @@ -1459,6 +1459,8 @@ export class TinyHyperGraphBusSolver extends TinyHyperGraphSolver { regionCache.lesserAngles = EMPTY_PREVIEW_INT32_ARRAY regionCache.greaterAngles = EMPTY_PREVIEW_INT32_ARRAY regionCache.layerMasks = EMPTY_PREVIEW_INT32_ARRAY + regionCache.traceLengthByLayer.fill(0) + regionCache.longestTraceLengthByLayer.fill(0) regionCache.existingCrossingLayerIntersections = 0 regionCache.existingSameLayerIntersections = 0 regionCache.existingEntryExitLayerChanges = 0 diff --git a/lib/bus-solver/previewRoutingState.ts b/lib/bus-solver/previewRoutingState.ts index c537b75..9675796 100644 --- a/lib/bus-solver/previewRoutingState.ts +++ b/lib/bus-solver/previewRoutingState.ts @@ -58,6 +58,10 @@ export const snapshotPreviewRoutingState = ( lesserAngles: new Int32Array(cache.lesserAngles), greaterAngles: new Int32Array(cache.greaterAngles), layerMasks: new Int32Array(cache.layerMasks), + traceLengthByLayer: new Float64Array(cache.traceLengthByLayer), + longestTraceLengthByLayer: new Float64Array( + cache.longestTraceLengthByLayer, + ), existingCrossingLayerIntersections: cache.existingCrossingLayerIntersections, existingSameLayerIntersections: cache.existingSameLayerIntersections, @@ -81,6 +85,10 @@ export const restorePreviewRoutingState = ( lesserAngles: new Int32Array(cache.lesserAngles), greaterAngles: new Int32Array(cache.greaterAngles), layerMasks: new Int32Array(cache.layerMasks), + traceLengthByLayer: new Float64Array(cache.traceLengthByLayer), + longestTraceLengthByLayer: new Float64Array( + cache.longestTraceLengthByLayer, + ), existingCrossingLayerIntersections: cache.existingCrossingLayerIntersections, existingSameLayerIntersections: cache.existingSameLayerIntersections, diff --git a/lib/computeRegionCost.ts b/lib/computeRegionCost.ts index 5100584..e1a1d81 100644 --- a/lib/computeRegionCost.ts +++ b/lib/computeRegionCost.ts @@ -1,8 +1,38 @@ export const DEFAULT_MIN_VIA_PAD_DIAMETER = 0.3 +export const DEFAULT_MIN_TRACE_WIDTH = 0.1 +export const DEFAULT_MIN_TRACE_CLEARANCE = 0.1 export const TRACE_VIA_MARGIN = 0.15 const traceWidth = 0.1 const IMPOSSIBLE_SINGLE_LAYER_INTERSECTION_COST = 10 +/** + * Estimates per-layer copper occupancy as swept trace area divided by region + * area. This cost is used only by post-solution optimization, where a complete + * baseline can always be restored, so utilization can remain physically + * meaningful without weakening initial-route completeness. + */ +export const computeTraceOccupancyCost = ( + regionArea: number, + traceLengthByLayer: ArrayLike, + longestTraceLengthByLayer: ArrayLike, + minTraceWidth = DEFAULT_MIN_TRACE_WIDTH, + minTraceClearance = DEFAULT_MIN_TRACE_CLEARANCE, +): number => { + let maxSharedTraceLength = 0 + for (let layerId = 0; layerId < traceLengthByLayer.length; layerId++) { + const sharedTraceLength = Math.max( + 0, + (traceLengthByLayer[layerId] ?? 0) - + (longestTraceLengthByLayer[layerId] ?? 0), + ) + maxSharedTraceLength = Math.max(maxSharedTraceLength, sharedTraceLength) + } + + const tracePitch = minTraceWidth + minTraceClearance + const sharedTraceArea = maxSharedTraceLength * tracePitch + return (sharedTraceArea / regionArea) ** 2 +} + export const isKnownSingleLayerMask = (regionAvailableZMask: number) => regionAvailableZMask > 0 && (regionAvailableZMask & (regionAvailableZMask - 1)) === 0 diff --git a/lib/core.ts b/lib/core.ts index 6dd6407..71793f3 100644 --- a/lib/core.ts +++ b/lib/core.ts @@ -2,7 +2,10 @@ import { BaseSolver } from "@tscircuit/solver-utils" import type { GraphicsObject } from "graphics-debug" import { convertToSerializedHyperGraph } from "./compat/convertToSerializedHyperGraph" import { + computeTraceOccupancyCost, computeRegionCost, + DEFAULT_MIN_TRACE_CLEARANCE, + DEFAULT_MIN_TRACE_WIDTH, DEFAULT_MIN_VIA_PAD_DIAMETER, isKnownSingleLayerMask, } from "./computeRegionCost" @@ -30,12 +33,37 @@ export type { StaticallyUnroutableRouteSummary } from "./static-reachability" const GREEDY_FINAL_ROUTE_MAX_ITERATIONS = 50e3 +const addSegmentLengthByLayer = ( + traceLengthByLayer: Float64Array, + longestTraceLengthByLayer: Float64Array, + layerMask: number, + segmentLength: number, +) => { + let layerCount = 0 + for (let layerId = 0; layerId < 32; layerId++) { + if ((layerMask & (1 << layerId)) !== 0) layerCount += 1 + } + if (layerCount === 0) return + + const lengthOnLayer = segmentLength / layerCount + for (let layerId = 0; layerId < 32; layerId++) { + if ((layerMask & (1 << layerId)) === 0) continue + traceLengthByLayer[layerId] += lengthOnLayer + longestTraceLengthByLayer[layerId] = Math.max( + longestTraceLengthByLayer[layerId], + lengthOnLayer, + ) + } +} + export const createEmptyRegionIntersectionCache = (): RegionIntersectionCache => ({ netIds: new Int32Array(0), lesserAngles: new Int32Array(0), greaterAngles: new Int32Array(0), layerMasks: new Int32Array(0), + traceLengthByLayer: new Float64Array(32), + longestTraceLengthByLayer: new Float64Array(32), existingCrossingLayerIntersections: 0, existingSameLayerIntersections: 0, existingEntryExitLayerChanges: 0, @@ -60,6 +88,12 @@ const cloneRegionIntersectionCache = ( lesserAngles: new Int32Array(regionIntersectionCache.lesserAngles), greaterAngles: new Int32Array(regionIntersectionCache.greaterAngles), layerMasks: new Int32Array(regionIntersectionCache.layerMasks), + traceLengthByLayer: new Float64Array( + regionIntersectionCache.traceLengthByLayer, + ), + longestTraceLengthByLayer: new Float64Array( + regionIntersectionCache.longestTraceLengthByLayer, + ), existingCrossingLayerIntersections: regionIntersectionCache.existingCrossingLayerIntersections, existingSameLayerIntersections: @@ -227,6 +261,12 @@ export interface TinyHyperGraphWorkingState { export interface TinyHyperGraphSolverOptions { minViaPadDiameter?: number + /** Minimum routed trace width in millimeters. Defaults to 0.1mm. */ + minTraceWidth?: number + /** Minimum trace-to-trace clearance in millimeters. Defaults to 0.1mm. */ + minTraceClearance?: number + /** Enables physical trace-occupancy scoring during solution optimization. */ + USE_TRACE_OCCUPANCY_COST?: boolean DISTANCE_TO_COST?: number RIP_THRESHOLD_START?: number RIP_THRESHOLD_END?: number @@ -244,6 +284,9 @@ export interface TinyHyperGraphSolverOptions { export interface TinyHyperGraphSolverOptionTarget { minViaPadDiameter: number + minTraceWidth: number + minTraceClearance: number + USE_TRACE_OCCUPANCY_COST: boolean DISTANCE_TO_COST: number RIP_THRESHOLD_START: number RIP_THRESHOLD_END: number @@ -270,6 +313,15 @@ export const applyTinyHyperGraphSolverOptions = ( if (options.minViaPadDiameter !== undefined) { solver.minViaPadDiameter = options.minViaPadDiameter } + if (options.minTraceWidth !== undefined) { + solver.minTraceWidth = options.minTraceWidth + } + if (options.minTraceClearance !== undefined) { + solver.minTraceClearance = options.minTraceClearance + } + if (options.USE_TRACE_OCCUPANCY_COST !== undefined) { + solver.USE_TRACE_OCCUPANCY_COST = options.USE_TRACE_OCCUPANCY_COST + } if (options.DISTANCE_TO_COST !== undefined) { solver.DISTANCE_TO_COST = options.DISTANCE_TO_COST } @@ -318,6 +370,9 @@ export const getTinyHyperGraphSolverOptions = ( solver: TinyHyperGraphSolverOptionTarget, ): TinyHyperGraphSolverOptions => ({ minViaPadDiameter: solver.minViaPadDiameter, + minTraceWidth: solver.minTraceWidth, + minTraceClearance: solver.minTraceClearance, + USE_TRACE_OCCUPANCY_COST: solver.USE_TRACE_OCCUPANCY_COST, DISTANCE_TO_COST: solver.DISTANCE_TO_COST, RIP_THRESHOLD_START: solver.RIP_THRESHOLD_START, RIP_THRESHOLD_END: solver.RIP_THRESHOLD_END, @@ -342,6 +397,7 @@ interface SegmentGeometryScratch { greaterAngle: number layerMask: number entryExitLayerChanges: number + length: number } export class TinyHyperGraphSolver extends BaseSolver { @@ -358,10 +414,17 @@ export class TinyHyperGraphSolver extends BaseSolver { greaterAngle: 0, layerMask: 0, entryExitLayerChanges: 0, + length: 0, } + private traceLengthByLayerScratch = new Float64Array(32) + private longestTraceLengthByLayerScratch = new Float64Array(32) + private traceOccupancyCostActive = false DISTANCE_TO_COST = 0.05 // 50mm = 1 cost unit (1 cost unit ~ 100% chance of failure) minViaPadDiameter = DEFAULT_MIN_VIA_PAD_DIAMETER + minTraceWidth = DEFAULT_MIN_TRACE_WIDTH + minTraceClearance = DEFAULT_MIN_TRACE_CLEARANCE + USE_TRACE_OCCUPANCY_COST = true RIP_THRESHOLD_START = 0.05 RIP_THRESHOLD_END = 0.8 @@ -734,6 +797,54 @@ export class TinyHyperGraphSolver extends BaseSolver { ) } + protected getRegionArea(regionId: RegionId): number { + return ( + this.topology.regionWidth[regionId] * this.topology.regionHeight[regionId] + ) + } + + protected computeTraceOccupancyCostForRegion( + regionId: RegionId, + traceLengthByLayer: ArrayLike, + longestTraceLengthByLayer: ArrayLike, + ): number { + if (!this.USE_TRACE_OCCUPANCY_COST || !this.traceOccupancyCostActive) { + return 0 + } + return computeTraceOccupancyCost( + this.getRegionArea(regionId), + traceLengthByLayer, + longestTraceLengthByLayer, + this.minTraceWidth, + this.minTraceClearance, + ) + } + + protected computeProspectiveTraceOccupancyCostForRegion( + regionId: RegionId, + regionCache: RegionIntersectionCache, + segmentGeometry: SegmentGeometryScratch, + ): number { + if (!this.USE_TRACE_OCCUPANCY_COST || !this.traceOccupancyCostActive) { + return 0 + } + this.traceLengthByLayerScratch.set(regionCache.traceLengthByLayer) + this.longestTraceLengthByLayerScratch.set( + regionCache.longestTraceLengthByLayer, + ) + addSegmentLengthByLayer( + this.traceLengthByLayerScratch, + this.longestTraceLengthByLayerScratch, + segmentGeometry.layerMask, + segmentGeometry.length, + ) + return this.computeTraceOccupancyCostForRegion( + regionId, + this.traceLengthByLayerScratch, + this.longestTraceLengthByLayerScratch, + ) + } + populateSegmentGeometryScratch( regionId: RegionId, port1Id: PortId, @@ -761,6 +872,10 @@ export class TinyHyperGraphSolver extends BaseSolver { scratch.greaterAngle = angle1 < angle2 ? angle2 : angle1 scratch.layerMask = (1 << z1) | (1 << z2) scratch.entryExitLayerChanges = z1 !== z2 ? 1 : 0 + scratch.length = Math.hypot( + topology.portX[port2Id] - topology.portX[port1Id], + topology.portY[port2Id] - topology.portY[port1Id], + ) return scratch } @@ -815,23 +930,45 @@ export class TinyHyperGraphSolver extends BaseSolver { const existingEntryExitLayerChanges = regionCache.existingEntryExitLayerChanges + newEntryExitLayerChanges const existingSegmentCount = lesserAngles.length + const traceLengthByLayer = this.USE_TRACE_OCCUPANCY_COST + ? new Float64Array(regionCache.traceLengthByLayer) + : regionCache.traceLengthByLayer + const longestTraceLengthByLayer = this.USE_TRACE_OCCUPANCY_COST + ? new Float64Array(regionCache.longestTraceLengthByLayer) + : regionCache.longestTraceLengthByLayer + if (this.USE_TRACE_OCCUPANCY_COST) { + addSegmentLengthByLayer( + traceLengthByLayer, + longestTraceLengthByLayer, + segmentGeometry.layerMask, + segmentGeometry.length, + ) + } state.regionIntersectionCaches[regionId] = { netIds, lesserAngles, greaterAngles, layerMasks, + traceLengthByLayer, + longestTraceLengthByLayer, existingSameLayerIntersections, existingCrossingLayerIntersections, existingEntryExitLayerChanges, existingSegmentCount, - existingRegionCost: this.computeRegionCostForRegion( - regionId, - existingSameLayerIntersections, - existingCrossingLayerIntersections, - existingEntryExitLayerChanges, - existingSegmentCount, - ), + existingRegionCost: + this.computeRegionCostForRegion( + regionId, + existingSameLayerIntersections, + existingCrossingLayerIntersections, + existingEntryExitLayerChanges, + existingSegmentCount, + ) + + this.computeTraceOccupancyCostForRegion( + regionId, + traceLengthByLayer, + longestTraceLengthByLayer, + ), } } @@ -1243,6 +1380,7 @@ export class TinyHyperGraphSolver extends BaseSolver { onAllRoutesRouted() { const { topology, state } = this + this.activateTraceOccupancyCost() const ripThresholdProgress = this.RIP_THRESHOLD_RAMP_ATTEMPTS <= 0 ? 1 @@ -1311,6 +1449,21 @@ export class TinyHyperGraphSolver extends BaseSolver { }) } + activateTraceOccupancyCost() { + if (!this.USE_TRACE_OCCUPANCY_COST || this.traceOccupancyCostActive) return + this.traceOccupancyCostActive = true + + for (let regionId = 0; regionId < this.topology.regionCount; regionId++) { + const regionCache = this.state.regionIntersectionCaches[regionId] + if (!regionCache) continue + regionCache.existingRegionCost += this.computeTraceOccupancyCostForRegion( + regionId, + regionCache.traceLengthByLayer, + regionCache.longestTraceLengthByLayer, + ) + } + } + onOutOfCandidates() { const { topology, state } = this const currentRouteId = state.currentRouteId @@ -1402,6 +1555,12 @@ export class TinyHyperGraphSolver extends BaseSolver { const neighborPortZ = topology.portZ[neighborPortId] const layerMask = (1 << currentPortZ) | (1 << neighborPortZ) const entryExitLayerChanges = currentPortZ !== neighborPortZ ? 1 : 0 + const segmentLength = this.traceOccupancyCostActive + ? Math.hypot( + topology.portX[neighborPortId] - topology.portX[currentPortId], + topology.portY[neighborPortId] - topology.portY[currentPortId], + ) + : 0 const [ newSameLayerIntersections, @@ -1431,7 +1590,19 @@ export class TinyHyperGraphSolver extends BaseSolver { newCrossLayerIntersections, regionCache.existingEntryExitLayerChanges + newEntryExitLayerChanges, regionCache.existingSegmentCount + 1, - ) - regionCache.existingRegionCost + ) + + this.computeProspectiveTraceOccupancyCostForRegion( + nextRegionId, + regionCache, + { + lesserAngle, + greaterAngle, + layerMask, + entryExitLayerChanges, + length: segmentLength, + }, + ) - + regionCache.existingRegionCost return ( currentCandidate.g + diff --git a/lib/index.ts b/lib/index.ts index 65eb648..7d68ce8 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -8,6 +8,9 @@ export * from "./selective-rerip-tiny-hyper-graph-solver" export * from "./bus-solver" export * from "./region-graph" export { + computeTraceOccupancyCost, + DEFAULT_MIN_TRACE_CLEARANCE, + DEFAULT_MIN_TRACE_WIDTH, DEFAULT_MIN_VIA_PAD_DIAMETER, TRACE_VIA_MARGIN, } from "./computeRegionCost" diff --git a/lib/poly.ts b/lib/poly.ts index 5cfbda0..a4cc23d 100644 --- a/lib/poly.ts +++ b/lib/poly.ts @@ -65,6 +65,7 @@ interface SegmentGeometryScratch { greaterAngle: number layerMask: number entryExitLayerChanges: number + length: number } const isRecord = (value: unknown): value is Record => @@ -620,6 +621,7 @@ export class PolyHyperGraphSolver extends TinyHyperGraphSolver { greaterAngle: 0, layerMask: 0, entryExitLayerChanges: 0, + length: 0, } constructor( @@ -654,6 +656,10 @@ export class PolyHyperGraphSolver extends TinyHyperGraphSolver { scratch.greaterAngle = position1 < position2 ? position2 : position1 scratch.layerMask = (1 << z1) | (1 << z2) scratch.entryExitLayerChanges = z1 !== z2 ? 1 : 0 + scratch.length = Math.hypot( + topology.portX[port2Id] - topology.portX[port1Id], + topology.portY[port2Id] - topology.portY[port1Id], + ) return scratch } @@ -676,6 +682,10 @@ export class PolyHyperGraphSolver extends TinyHyperGraphSolver { ) } + protected override getRegionArea(regionId: RegionId): number { + return this.topology.regionArea[regionId] + } + override visualize(): GraphicsObject { return visualizePolyHyperGraph(this) } diff --git a/lib/section-solver/index.ts b/lib/section-solver/index.ts index e66f4a1..b9d5422 100644 --- a/lib/section-solver/index.ts +++ b/lib/section-solver/index.ts @@ -11,7 +11,11 @@ import { type TinyHyperGraphTopology, type TinyHyperGraphSolverOptions, } from "../core" -import { DEFAULT_MIN_VIA_PAD_DIAMETER } from "../computeRegionCost" +import { + DEFAULT_MIN_TRACE_CLEARANCE, + DEFAULT_MIN_TRACE_WIDTH, + DEFAULT_MIN_VIA_PAD_DIAMETER, +} from "../computeRegionCost" import { shuffle } from "../shuffle" import type { PortId, @@ -103,6 +107,12 @@ const cloneRegionIntersectionCache = ( lesserAngles: new Int32Array(regionIntersectionCache.lesserAngles), greaterAngles: new Int32Array(regionIntersectionCache.greaterAngles), layerMasks: new Int32Array(regionIntersectionCache.layerMasks), + traceLengthByLayer: new Float64Array( + regionIntersectionCache.traceLengthByLayer, + ), + longestTraceLengthByLayer: new Float64Array( + regionIntersectionCache.longestTraceLengthByLayer, + ), existingCrossingLayerIntersections: regionIntersectionCache.existingCrossingLayerIntersections, existingSameLayerIntersections: @@ -396,6 +406,7 @@ const createSolvedSolverFromRegionSegments = ( ) => { const solver = new TinyHyperGraphSolver(topology, problem, options) applyRouteSegmentsToSolver(solver, routeSegmentsByRegion) + solver.activateTraceOccupancyCost() return solver } @@ -881,6 +892,9 @@ export class TinyHyperGraphSectionSolver extends BaseSolver { DISTANCE_TO_COST = 0.05 minViaPadDiameter = DEFAULT_MIN_VIA_PAD_DIAMETER + minTraceWidth = DEFAULT_MIN_TRACE_WIDTH + minTraceClearance = DEFAULT_MIN_TRACE_CLEARANCE + USE_TRACE_OCCUPANCY_COST = true VERBOSE = false RIP_THRESHOLD_START = 0.05 diff --git a/lib/types.ts b/lib/types.ts index b109000..4b62470 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -25,6 +25,8 @@ export interface DynamicAnglePairArrays { } export interface RegionIntersectionCache extends DynamicAnglePairArrays { + traceLengthByLayer: Float64Array + longestTraceLengthByLayer: Float64Array existingSameLayerIntersections: Integer existingCrossingLayerIntersections: Integer existingEntryExitLayerChanges: Integer diff --git a/tests/solver/on-all-routes-routed.test.ts b/tests/solver/on-all-routes-routed.test.ts index 16431f3..8fa748e 100644 --- a/tests/solver/on-all-routes-routed.test.ts +++ b/tests/solver/on-all-routes-routed.test.ts @@ -15,6 +15,8 @@ const createRegionCache = ( lesserAngles: new Int32Array(0), greaterAngles: new Int32Array(0), layerMasks: new Int32Array(0), + traceLengthByLayer: new Float64Array(32), + longestTraceLengthByLayer: new Float64Array(32), existingCrossingLayerIntersections: 0, existingSameLayerIntersections: 0, existingEntryExitLayerChanges: 0, diff --git a/tests/solver/region-net-id.test.ts b/tests/solver/region-net-id.test.ts index 528bacf..513fb0a 100644 --- a/tests/solver/region-net-id.test.ts +++ b/tests/solver/region-net-id.test.ts @@ -13,6 +13,8 @@ const createRegionCache = ( lesserAngles: new Int32Array(0), greaterAngles: new Int32Array(0), layerMasks: new Int32Array(0), + traceLengthByLayer: new Float64Array(32), + longestTraceLengthByLayer: new Float64Array(32), existingCrossingLayerIntersections: 0, existingSameLayerIntersections: 0, existingEntryExitLayerChanges: 0, diff --git a/tests/solver/trace-occupancy-activation.test.ts b/tests/solver/trace-occupancy-activation.test.ts new file mode 100644 index 0000000..b2edc5f --- /dev/null +++ b/tests/solver/trace-occupancy-activation.test.ts @@ -0,0 +1,42 @@ +import { expect, test } from "bun:test" +import { + type TinyHyperGraphProblem, + TinyHyperGraphSolver, + type TinyHyperGraphTopology, +} from "lib" + +test("trace occupancy activates only after a complete baseline exists", () => { + const topology: TinyHyperGraphTopology = { + portCount: 0, + regionCount: 1, + regionIncidentPorts: [[]], + incidentPortRegion: [], + regionWidth: new Float64Array([2]), + regionHeight: new Float64Array([1]), + regionCenterX: new Float64Array([1]), + regionCenterY: new Float64Array([0.5]), + portAngleForRegion1: new Int32Array(0), + portX: new Float64Array(0), + portY: new Float64Array(0), + portZ: new Int32Array(0), + } + const problem: TinyHyperGraphProblem = { + routeCount: 0, + portSectionMask: new Int8Array(0), + routeStartPort: new Int32Array(0), + routeEndPort: new Int32Array(0), + routeNet: new Int32Array(0), + regionNetId: new Int32Array([-1]), + } + const solver = new TinyHyperGraphSolver(topology, problem, { + RIP_THRESHOLD_START: 1, + }) + const regionCache = solver.state.regionIntersectionCaches[0]! + regionCache.traceLengthByLayer[0] = 7 + regionCache.longestTraceLengthByLayer[0] = 4 + + expect(regionCache.existingRegionCost).toBe(0) + solver.onAllRoutesRouted() + expect(regionCache.existingRegionCost).toBeCloseTo(0.09) + expect(solver.solved).toBe(true) +}) diff --git a/tests/solver/trace-occupancy-cost.test.ts b/tests/solver/trace-occupancy-cost.test.ts new file mode 100644 index 0000000..81a5dc8 --- /dev/null +++ b/tests/solver/trace-occupancy-cost.test.ts @@ -0,0 +1,10 @@ +import { expect, test } from "bun:test" +import { computeTraceOccupancyCost } from "lib" + +test("trace occupancy charges only shared swept copper on the busiest layer", () => { + expect(computeTraceOccupancyCost(2, [4], [4])).toBe(0) + expect(computeTraceOccupancyCost(2, [7, 20], [4, 19])).toBeCloseTo(0.09) + expect( + computeTraceOccupancyCost(2, [7, 20], [4, 19], 0.15, 0.25), + ).toBeCloseTo(0.36) +})