perf(533-2 2차): E04 탐색 프로세스 병렬 · 솔버 수렴 묶음 빼기 · 창 배열 덜 펴기 — 1 km 띠 287 s → 32 s

- Search: 후보 덩이를 프로세스로 나눠 셈(workers · 0 = 자동 · 격자 5만 칸 이상이면 CPU − 2 · 12 까지) · 낸 차례대로 합침 — 일꾼 수와 무관하게 같은 값
- Search: 지층은 임시 파일로 일꾼에 넘김(spawn 관이면 일꾼이 차례로 떠 12 개 6 s) · 돌려받는 칸은 iy · ix · group(int32)만
- Search: 고친 노드 판정 = 반지름 시작 때 셀 최소(덩이 나눔과 무관) · 1차 SearchParams 칸 · SearchResult 꼴 그대로
- Search: 1단계 거르기 = 활동면 높이를 창 전체가 아니라 걸친 칸에서만 · 큰 창 배열 펴지 않음
- Column: Bishop · Janbu 고정점 — 기둥별 상수 미리 · 수렴한 활동체 기둥은 빼며 셈(값 같음)
- 측정(검증_sub5 57 측점 · 노드 2 m · 구 + 타원체 2): 1차 287 s → 한 프로세스 217 s → 12 프로세스 32.1 s · 셀 9.7만 F 전후 최대 차 6e-15 · 전역 최소 1.099 그대로
- 시험: 일꾼 1 · 2 같은 값

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 21:22:28 +09:00
co-authored by Claude Opus 5.5
parent fccd316a06
commit 834a86e76e
3 changed files with 182 additions and 58 deletions
+60 -23
View File
@@ -244,6 +244,27 @@ def _finish(res: np.ndarray, drive: np.ndarray) -> np.ndarray:
return out return out
def _fixed_point(st: dict, f: np.ndarray, coef: dict, step) -> tuple[np.ndarray, np.ndarray]:
"""F = step(기둥 묶음, F) 고정점 — 수렴한 활동체의 기둥은 빼며 셈(값은 빼지 않을 때와 같음).
coef = 기둥별 상수 배열(group 포함) · 반환 (F, 수렴 여부).
"""
n = st["n"]
sub = coef
conv = np.zeros(n, bool)
for _ in range(MAX_ITER):
f_new = step(sub, f)
done = np.abs(f_new - f) < TOL
f = np.where(conv, f, f_new)
conv |= done
if conv.all():
break
live = ~conv[sub["group"]]
if live.mean() < 0.6: # 남은 기둥만 — 매번 줄이면 줄이는 값이 더 듦
sub = {k: v[live] for k, v in sub.items()}
return f, conv
def _bishop(st: dict, f0: np.ndarray) -> tuple[np.ndarray, np.ndarray]: def _bishop(st: dict, f0: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
grp, n = st["group"], st["n"] grp, n = st["group"], st["n"]
wq = st["w"] + st["q"] wq = st["w"] + st["q"]
@@ -252,19 +273,29 @@ def _bishop(st: dict, f0: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
lift = np.zeros(n) lift = np.zeros(n)
np.maximum.at(lift, grp, neg) np.maximum.at(lift, grp, neg)
f = np.where(np.isfinite(f0), np.maximum(f0, 0.05), 1.0) + lift f = np.where(np.isfinite(f0), np.maximum(f0, 0.05), 1.0) + lift
conv = np.zeros(n, bool) # S_res = num / mα · N = (W+Q − S sinα)/cosψ → 구동 = d1 − Σ S_res·k_n / F (기둥별 상수 미리)
for _ in range(MAX_ITER): num = st["c"] * st["a_h"] + np.maximum(wq - st["u_ah"], 0.0) * st["tan_phi"]
s_res, _ = _s_res(st, f) d1 = _gsum(
s = s_res / np.maximum(f[grp], 1e-3) grp, -st["p"] * wq + st["e"] * st["kh"] * st["w"] + wq * st["m_n"] / st["cos_psi"], n
nn = (wq - s * st["sin_a"]) / st["cos_psi"] # 연직 평형에서 N )
res = _gsum(grp, st["arm_s"] * s_res, n) coef = {
drive = _gsum(grp, -st["p"] * wq + st["e"] * st["kh"] * st["w"] + nn * st["m_n"], n) "group": grp,
f_new = _finish(res, drive) "num": num,
done = np.abs(f_new - f) < TOL "cos_psi": st["cos_psi"],
f = np.where(conv, f, f_new) "k": st["sin_a"] * st["tan_phi"],
conv |= done "arm_s": st["arm_s"],
if conv.all(): "k_n": st["sin_a"] * st["m_n"] / st["cos_psi"],
break }
def step(c: dict, f: np.ndarray) -> np.ndarray:
g = c["group"]
fg = np.maximum(f, 1e-3)
s_res = c["num"] / (c["cos_psi"] + c["k"] / fg[g])
res = _gsum(g, c["arm_s"] * s_res, n)
drive = d1 - _gsum(g, s_res * c["k_n"], n) / fg
return _finish(res, drive)
f, conv = _fixed_point(st, f, coef, step)
_, m_a = _s_res(st, f) _, m_a = _s_res(st, f)
ma_min = _gmin(grp, m_a, n) ma_min = _gmin(grp, m_a, n)
# 못 쓰는 면 — 수렴 안 함 · mα ≤ 0(바닥 수직력이 음) · F ≤ 0 → nan(후보에서 빠짐) # 못 쓰는 면 — 수렴 안 함 · mα ≤ 0(바닥 수직력이 음) · F ≤ 0 → nan(후보에서 빠짐)
@@ -280,17 +311,23 @@ def _gmin(grp: np.ndarray, values: np.ndarray, n: int) -> np.ndarray:
def _janbu(st: dict, f0: np.ndarray) -> np.ndarray: def _janbu(st: dict, f0: np.ndarray) -> np.ndarray:
grp, n = st["group"], st["n"] grp, n = st["group"], st["n"]
drive = _gsum(grp, (st["w"] + st["q"]) * st["tan_a"] + st["kh"] * st["w"], n) wq = st["w"] + st["q"]
drive = _gsum(grp, wq * st["tan_a"] + st["kh"] * st["w"], n)
f = np.where(np.isfinite(f0), np.maximum(f0, 0.05), 1.0) f = np.where(np.isfinite(f0), np.maximum(f0, 0.05), 1.0)
conv = np.zeros(n, bool) num = st["c"] * st["a_h"] + np.maximum(wq - st["u_ah"], 0.0) * st["tan_phi"]
for _ in range(MAX_ITER): coef = {
s_res, _ = _s_res(st, f) "group": grp,
f_new = _finish(_gsum(grp, s_res / st["cos_a"], n), drive) "num": num / st["cos_a"],
done = np.abs(f_new - f) < TOL "cos_psi": st["cos_psi"],
f = np.where(conv, f, f_new) "k": st["sin_a"] * st["tan_phi"],
conv |= done }
if conv.all():
break def step(c: dict, f: np.ndarray) -> np.ndarray:
g = c["group"]
s_res = c["num"] / (c["cos_psi"] + c["k"] / np.maximum(f, 1e-3)[g])
return _finish(_gsum(g, s_res, n), drive)
f, conv = _fixed_point(st, f, coef, step)
f[~conv | (f <= 0)] = np.nan f[~conv | (f <= 0)] = np.nan
return f return f
+110 -35
View File
@@ -9,6 +9,7 @@
구는 활동체 지표 평균 낙하 방향(Scoops3D 2.1). 구는 활동체 지표 평균 낙하 방향(Scoops3D 2.1).
- 한 구가 지표를 여러 덩이로 자르면 덩이마다 셈(ndimage.label · 8 이웃). - 한 구가 지표를 여러 덩이로 자르면 덩이마다 셈(ndimage.label · 8 이웃).
- 격자 가장자리 · DEM 빈칸에 닿은 덩이는 버림(잘린 질량 막음). - 격자 가장자리 · DEM 빈칸에 닿은 덩이는 버림(잘린 질량 막음).
- 후보 덩이는 프로세스로 나눠 셈 · 낸 차례대로 합침 — 일꾼 수와 무관하게 같은 값.
다스리는 안전율(governing): 다스리는 안전율(governing):
- 못 뚫는 층(기본 발파암 = 연암)에 잘린 바닥 면적 비 ≥ 10 % → Janbu 보정으로 전환 - 못 뚫는 층(기본 발파암 = 연암)에 잘린 바닥 면적 비 ≥ 10 % → Janbu 보정으로 전환
@@ -21,8 +22,14 @@
from __future__ import annotations from __future__ import annotations
import math import math
import os
import pickle
import tempfile
import time import time
import weakref
from concurrent.futures import ProcessPoolExecutor
from dataclasses import dataclass, field from dataclasses import dataclass, field
from types import SimpleNamespace
import numpy as np import numpy as np
from scipy import ndimage from scipy import ndimage
@@ -56,6 +63,7 @@ class SearchParams:
a_max: float = math.inf a_max: float = math.inf
ratios: tuple[float, ...] = (1.0, 2.0) # 구(늘 봄) + 타원체 축 방향 배수(구 임계 노드에서) ratios: tuple[float, ...] = (1.0, 2.0) # 구(늘 봄) + 타원체 축 방향 배수(구 임계 노드에서)
sub: int = 2 # 셀 안 표본 sub×sub(부분 기둥) sub: int = 2 # 셀 안 표본 sub×sub(부분 기둥)
workers: int = 0 # 프로세스 수 · 0 = 자동(격자 5만 칸 이상이면 CPU − 2 · 12 까지)
refine_rounds: int = 4 # 가장 고운 간격에서 더 내려가 보는 횟수 상한 refine_rounds: int = 4 # 가장 고운 간격에서 더 내려가 보는 횟수 상한
# 고운 탐색 · 타원체는 F < max(이 값, 전역 최소 × 1.2) 로 고친 노드만(높은 곳 지도는 거친 값) # 고운 탐색 · 타원체는 F < max(이 값, 전역 최소 × 1.2) 로 고친 노드만(높은 곳 지도는 거친 값)
refine_below: float = 2.0 refine_below: float = 2.0
@@ -103,13 +111,23 @@ def trial_columns(
ixg = ix0[:, None, None] + off[None, None, :] ixg = ix0[:, None, None] + off[None, None, :]
iyg = iy0[:, None, None] + off[None, :, None] iyg = iy0[:, None, None] + off[None, :, None]
inb = (ixg >= 0) & (ixg < nx) & (iyg >= 0) & (iyg < ny) inb = (ixg >= 0) & (ixg < nx) & (iyg >= 0) & (iyg < ny)
ixc, iyc, inb = np.broadcast_arrays(np.clip(ixg, 0, nx - 1), np.clip(iyg, 0, ny - 1), inb) nw = 2 * w + 1
shape = ixc.shape shape = (n_t, nw, nw)
d = None if ratio == 1.0 else np.asarray(directions, float) d = None if ratio == 1.0 else np.asarray(directions, float)
k2 = 1.0 / (ratio * ratio) k2 = 1.0 / (ratio * ratio)
def remainder(t, xs, ys):
"""r² − (중심에서 수평 거리 꼴)² — 0 보다 크면 활동면이 그 점 위를 지남."""
dxp = xs - centers[t, 0]
dyp = ys - centers[t, 1]
if d is None:
return r * r - dxp * dxp - dyp * dyp
pp = dxp * d[t, 0] + dyp * d[t, 1]
qq = -dxp * d[t, 1] + dyp * d[t, 0]
return r * r - pp * pp - k2 * qq * qq
def surface(t, xs, ys): def surface(t, xs, ys):
"""후보 t 의 활동면 — (rem, z_s, ∂z/∂x, ∂z/∂y). 원소별(납작 · 창 모두).""" """후보 t 의 활동면 — (rem, z_s, ∂z/∂x, ∂z/∂y). 원소별(납작)."""
dxp = xs - centers[t, 0] dxp = xs - centers[t, 0]
dyp = ys - centers[t, 1] dyp = ys - centers[t, 1]
if d is None: if d is None:
@@ -126,13 +144,16 @@ def trial_columns(
return rem, centers[t, 2] - root, gx, gy return rem, centers[t, 2] - root, gx, gy
# 1) 셀 중심으로 거르기 — 걸친 셀과 그 이웃만 셀 안 표본을 봄(창 대부분은 허공 · 땅속 밖) # 1) 셀 중심으로 거르기 — 걸친 셀과 그 이웃만 셀 안 표본을 봄(창 대부분은 허공 · 땅속 밖)
t_grid = np.broadcast_to(np.arange(n_t)[:, None, None], shape) # 창 칸 번호 → (후보, 셀) — 큰 창 배열을 펴지 않고 셈
xs0 = strata.x[0] + ixg * dx def cell_of(flat):
ys0 = strata.y[0] + iyg * dx t = flat // (nw * nw)
rem0, zs0, _, _ = surface(t_grid, xs0, ys0) return t, iy0[t] + off[(flat // nw) % nw], ix0[t] + off[flat % nw]
rem0 = remainder(np.arange(n_t)[:, None, None], strata.x[0] + ixg * dx, strata.y[0] + iyg * dx)
in0 = np.flatnonzero(inb & (rem0 > 0)) in0 = np.flatnonzero(inb & (rem0 > 0))
yy0, xx0 = iyc.ravel()[in0], ixc.ravel()[in0] t0, yy0, xx0 = cell_of(in0)
zb0 = np.maximum(zs0.ravel()[in0], strata.hard_top[yy0, xx0]) zs0 = centers[t0, 2] - np.sqrt(rem0.ravel()[in0])
zb0 = np.maximum(zs0, strata.hard_top[yy0, xx0])
act0 = np.zeros(shape, bool) act0 = np.zeros(shape, bool)
act0.ravel()[in0[zb0 < strata.z_top[yy0, xx0]]] = True act0.ravel()[in0[zb0 < strata.z_top[yy0, xx0]]] = True
cand = act0 if sub == 1 else ndimage.binary_dilation(act0, structure=_STRUCT) & inb cand = act0 if sub == 1 else ndimage.binary_dilation(act0, structure=_STRUCT) & inb
@@ -142,9 +163,7 @@ def trial_columns(
empty = (Columns(e, e, e, np.zeros(0), np.zeros(0), np.zeros(0), np.zeros((0, 3))), e, big) empty = (Columns(e, e, e, np.zeros(0), np.zeros(0), np.zeros(0), np.zeros((0, 3))), e, big)
if not len(ci): if not len(ci):
return empty return empty
tt = t_grid.ravel()[ci] tt, cy, cx = cell_of(ci)
cy = iyc.ravel()[ci]
cx = ixc.ravel()[ci]
hard = strata.hard_top[cy, cx] hard = strata.hard_top[cy, cx]
# 2) 부분 기둥 — sub×sub 점 # 2) 부분 기둥 — sub×sub 점
@@ -207,6 +226,42 @@ def trial_columns(
return cols, trial, big return cols, trial, big
_PART_OPTS = ("v_min", "v_max", "a_min", "a_max", "sub", "groundwater", "seismic_kh")
_PART: dict = {} # 일꾼 프로세스의 지층 · 조건
def _part_init(strata: Strata | str, opts: dict) -> None:
if isinstance(strata, str):
with open(strata, "rb") as fh:
strata = pickle.load(fh)
_PART.update(strata=strata, **opts)
def _open_pool(strata: Strata, opts: dict, n_workers: int) -> ProcessPoolExecutor:
"""일꾼 풀 — 지층은 임시 파일로 넘김(spawn 관으로 넘기면 일꾼이 차례로 뜸 · 12 개 6 s)."""
fd, path = tempfile.mkstemp(suffix=".e04.pkl")
with os.fdopen(fd, "wb") as fh:
pickle.dump(strata, fh, protocol=pickle.HIGHEST_PROTOCOL)
pool = ProcessPoolExecutor(n_workers, initializer=_part_init, initargs=(path, opts))
weakref.finalize(pool, os.remove, path) # 멈춤 · 오류로 빠져나가도 지움
return pool
def _part(job: tuple, k: dict | None = None) -> tuple:
"""후보 덩이 하나 — 기둥 + 솔버. 셀 합치기에 쓰는 칸(iy · ix · group)만 돌려줌(옮길 양 줄임)."""
xyz, dirs, r, ratio = job
k = k or _PART
st = k["strata"]
cols, trial, big = trial_columns(
st, xyz, r, ratio, dirs, k["v_min"], k["v_max"], k["a_min"], k["a_max"], k["sub"]
)
res = solve(st, cols, k["groundwater"], k["seismic_kh"]) if len(trial) else None
light = SimpleNamespace(
iy=cols.iy.astype(np.int32), ix=cols.ix.astype(np.int32), group=cols.group.astype(np.int32)
)
return light, trial, big, res
def governing(res: dict) -> tuple[np.ndarray, np.ndarray]: def governing(res: dict) -> tuple[np.ndarray, np.ndarray]:
"""다스리는 안전율 · 방법(0 Bishop · 1 Janbu 보정) — 머리 주석 규칙.""" """다스리는 안전율 · 방법(0 Bishop · 1 Janbu 보정) — 머리 주석 규칙."""
jc = np.where(np.isfinite(res["janbu_corrected"]), res["janbu_corrected"], np.inf) jc = np.where(np.isfinite(res["janbu_corrected"]), res["janbu_corrected"], np.inf)
@@ -276,6 +331,14 @@ def search(strata: Strata, params: SearchParams | None = None) -> SearchResult:
z = strata.z_top[cy, cx] + p.h_min + ih * p.h_step z = strata.z_top[cy, cx] + p.h_min + ih * p.h_step
return np.column_stack([x, y, z]), cy, cx, p.h_min + ih * p.h_step return np.column_stack([x, y, z]), cy, cx, p.h_min + ih * p.h_step
# 후보 덩이 = 프로세스로 나눠 셈(_part) · 결과는 낸 차례대로 합침 → 일꾼 수와 무관한 같은 값
n_workers = p.workers or (
min(12, max(1, (os.cpu_count() or 2) - 2)) if ny * nx >= 50_000 else 1
)
opts = {k: getattr(p, k) for k in _PART_OPTS}
pool = _open_pool(strata, opts, n_workers) if n_workers > 1 else None
local = {"strata": strata, **opts}
def run(ids: np.ndarray, ratios: tuple[float, ...] = (1.0,)) -> set[int]: def run(ids: np.ndarray, ratios: tuple[float, ...] = (1.0,)) -> set[int]:
"""노드들의 모든 반지름 · 비 — 셀 최소를 고친 노드 번호를 돌려줌.""" """노드들의 모든 반지름 · 비 — 셀 최소를 고친 노드 번호를 돌려줌."""
improved: set[int] = set() improved: set[int] = set()
@@ -297,27 +360,20 @@ def search(strata: Strata, params: SearchParams | None = None) -> SearchResult:
if not len(live): if not len(live):
continue continue
w_now = (2 * math.ceil(r * max(ratio, 1) / strata.dx) + 3) ** 2 w_now = (2 * math.ceil(r * max(ratio, 1) / strata.dx) + 3) ** 2
chunk = max(1, BATCH_CELLS // max(w_now, 1)) # 메모리 = 일꾼 수 합쳐 BATCH_CELLS · 일꾼마다 한 덩이 이상(작은 고운 탐색도 나눔)
for s in range(0, len(live), chunk): chunk = max(1, BATCH_CELLS // (w_now * n_workers))
part = live[s : s + chunk] chunk = min(chunk, -(-len(live) // n_workers))
d_part = dirs[part] if ratio != 1.0 else None parts = [live[s : s + chunk] for s in range(0, len(live), chunk)]
cols, trial, big = trial_columns( jobs = [(xyz[q], dirs[q] if ratio != 1.0 else None, r, ratio) for q in parts]
strata, outs = pool.map(_part, jobs) if pool else (_part(j, local) for j in jobs)
xyz[part], # 고친 노드 판정 기준 = 이 반지름 시작 값(덩이 나눔과 무관)
r, ref = f_min.copy()
ratio, thr = max(p.refine_below, 1.2 * best["governing"])
d_part, for part, (cols, trial, big, res) in zip(parts, outs):
p.v_min,
p.v_max,
p.a_min,
p.a_max,
p.sub,
)
done[part[big > p.v_max]] = True done[part[big > p.v_max]] = True
counters["trials"] += len(part) counters["trials"] += len(part)
if not len(trial): if not len(trial):
continue continue
res = solve(strata, cols, p.groundwater, p.seismic_kh)
counters["evaluated"] += len(trial) counters["evaluated"] += len(trial)
f, meth = governing(res) f, meth = governing(res)
improved |= _update( improved |= _update(
@@ -331,7 +387,8 @@ def search(strata: Strata, params: SearchParams | None = None) -> SearchResult:
volume, volume,
method, method,
node_map, node_map,
max(p.refine_below, 1.2 * best["governing"]), thr,
ref,
) )
k = ( k = (
int(np.nanargmin(np.where(np.isfinite(f), f, np.inf))) int(np.nanargmin(np.where(np.isfinite(f), f, np.inf)))
@@ -396,6 +453,8 @@ def search(strata: Strata, params: SearchParams | None = None) -> SearchResult:
if ell and len(crit): if ell and len(crit):
run(crit, ell) run(crit, ell)
if pool:
pool.shutdown()
f_out = np.where(np.isfinite(f_min), f_min, np.nan) f_out = np.where(np.isfinite(f_min), f_min, np.nan)
return SearchResult( return SearchResult(
f_min=f_out, f_min=f_out,
@@ -410,19 +469,35 @@ def search(strata: Strata, params: SearchParams | None = None) -> SearchResult:
def _update( def _update(
f, meth, res, cols, trial, node_ids, f_min, volume, method, node_map, refine_below=np.inf f,
meth,
res,
cols,
trial,
node_ids,
f_min,
volume,
method,
node_map,
refine_below=np.inf,
ref=None,
) -> set[int]: ) -> set[int]:
"""셀마다 더 낮은 F 면 바꿈 — 같은 셀 여럿이면 가장 낮은 것(내림차순으로 써 마지막이 이김).""" """셀마다 더 낮은 F 면 바꿈 — 같은 셀 여럿이면 가장 낮은 것(내림차순으로 써 마지막이 이김).
돌려주는 노드 = ref(없으면 f_min) 보다 낮춘 후보의 노드 중 F < refine_below.
"""
fc = f[cols.group] fc = f[cols.group]
ok = np.isfinite(fc) ok = np.isfinite(fc)
iy, ix, grp, fc = cols.iy[ok], cols.ix[ok], cols.group[ok], fc[ok] iy, ix, grp, fc = cols.iy[ok], cols.ix[ok], cols.group[ok], fc[ok]
order = np.argsort(-fc, kind="stable") order = np.argsort(-fc, kind="stable")
iy, ix, grp, fc = iy[order], ix[order], grp[order], fc[order] iy, ix, grp, fc = iy[order], ix[order], grp[order], fc[order]
ref = f_min if ref is None else ref
hit = (fc < ref[iy, ix] * (1 - 1e-6)) & (fc < refine_below)
seeds = set(np.unique(node_ids[trial[grp[hit]]]).tolist())
better = fc < f_min[iy, ix] * (1 - 1e-6) better = fc < f_min[iy, ix] * (1 - 1e-6)
iy, ix, grp, fc = iy[better], ix[better], grp[better], fc[better] iy, ix, grp, fc = iy[better], ix[better], grp[better], fc[better]
f_min[iy, ix] = fc f_min[iy, ix] = fc
volume[iy, ix] = res["volume"][grp] volume[iy, ix] = res["volume"][grp]
method[iy, ix] = meth[grp] method[iy, ix] = meth[grp]
nid = node_ids[trial[grp]] node_map[iy, ix] = node_ids[trial[grp]]
node_map[iy, ix] = nid return seeds
return set(np.unique(nid[fc < refine_below]).tolist())
@@ -85,3 +85,15 @@ def test_wet_cone_pore_pressure_ratio():
groundwater={"mode": "ru", "ru": 0.4}, groundwater={"mode": "ru", "ru": 0.4},
) )
assert search(st, p).best["governing"] == pytest.approx(1.626, rel=0.03) assert search(st, p).best["governing"] == pytest.approx(1.626, rel=0.03)
def test_workers_same_result():
"""프로세스 나눔 — 일꾼 수와 무관하게 셀별 최소 F · 전역 최소가 같음."""
st = _embankment(10 / np.tan(np.radians(30)), 19, 20, 8.64)
kw = dict(box=(-2, 10, -4, 4), h_min=4, h_max=20, spacing=2.0, coarse=2, max_depth=16)
kw.update(v_min=50, v_max=2e4, ratios=(1.0, 2.0))
one = search(st, SearchParams(workers=1, **kw))
two = search(st, SearchParams(workers=2, **kw))
np.testing.assert_array_equal(one.f_min, two.f_min)
assert one.best["governing"] == two.best["governing"]
assert one.evaluated == two.evaluated