-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_coupling.py
More file actions
1215 lines (984 loc) · 47.3 KB
/
Copy pathtest_coupling.py
File metadata and controls
1215 lines (984 loc) · 47.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
化学-物理耦合单元测试
Unit Tests for Chemistry-Physics Coupling Module (coupled_simulation.py)
测试场景:
1. 酸碱中和(HCl + NaOH):pH 梯度平滑传播,质量守恒
2. 碳酸盐平衡(CO₂-H₂O):PHREEQCRM 与物理时间步协调
3. 对流-反应(剪切流):平流不引起数值振荡
运行方法:
python -m pytest test_coupling.py -v
# 或直接运行:
python test_coupling.py
"""
import unittest
from unittest.mock import MagicMock, patch, PropertyMock
import numpy as np
import sys
import os
# ── 将项目根目录加入搜索路径 ──────────────────────────────────────────────
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from coupled_simulation import (
Particle,
ChemicalField,
CoupledSimulation,
CHEM_GRID_DIM,
CHEM_STEP_RATIO,
CHEM_MAX_RETRIES,
DEFAULT_PARTICLE_VOLUME,
)
from utils import build_consecutive_mapping
# ══════════════════════════════════════════════════════════════════════════
# 辅助函数
# ══════════════════════════════════════════════════════════════════════════
def _make_mock_rm(n_cells: int, n_comps: int, conc_out: np.ndarray = None):
"""
创建模拟 PHREEQCRM 对象。
Args:
n_cells: 化学单元数量
n_comps: 化学组分数量
conc_out: RunCells 后返回的浓度(默认与输入相同)
"""
rm = MagicMock()
rm.GetComponentCount.return_value = n_comps
rm.GetComponents.return_value = [f"comp{i}" for i in range(n_comps)]
rm.RunCells.return_value = 0 # 成功
if conc_out is not None:
rm.GetConcentrations.return_value = conc_out.flatten().tolist()
else:
# 默认:返回全零(RunCells 不改变浓度,仅测试流程)
rm.GetConcentrations.return_value = [0.0] * (n_cells * n_comps)
return rm
def _make_coupled_sim_with_particles(
positions: np.ndarray,
n_comps: int = 2,
initial_solutions: np.ndarray = None,
rm=None,
chem_step_ratio: int = 1,
):
"""
创建带测试粒子的 CoupledSimulation 实例。
Args:
positions: (N, 3) 粒子坐标
n_comps: 化学组分数
initial_solutions: (N, n_comps) 初始浓度,None 则全零
rm: 模拟 PHREEQCRM 对象(None 则自动创建)
chem_step_ratio: 化学步频率
Returns:
sim: CoupledSimulation
"""
n = len(positions)
n_cells = CHEM_GRID_DIM ** 3
if rm is None:
conc_out = (
initial_solutions.flatten()
if initial_solutions is not None
else np.zeros(n_cells * n_comps)
)
rm = _make_mock_rm(n_cells, n_comps, np.zeros((n_cells, n_comps)))
# 不注入 mesh_system,直接管理粒子数组
sim = CoupledSimulation(
mesh_system=None,
phreeqc_rm=rm,
chem_step_ratio=chem_step_ratio,
)
sim.n_comps = n_comps
sim.comp_names = [f"comp{i}" for i in range(n_comps)]
# 手动注入粒子数据
sim._particle_cell_ids = np.full(n, -1, dtype=np.int32)
sim._particle_solutions = (
initial_solutions.copy()
if initial_solutions is not None
else np.zeros((n, n_comps), dtype=np.float64)
)
# 以位置数组模拟 mesh_system
mock_mesh = MagicMock()
mock_mesh.current_particle_num = n
mock_mesh.particle_positions = positions.astype(np.float32)
sim.mesh_system = mock_mesh
mock_mesh.step_simulation.return_value = ([], [])
# 建立初始映射
sim._update_particle_mapping()
return sim
# ══════════════════════════════════════════════════════════════════════════
# 测试套件 1:Particle 数据结构
# ══════════════════════════════════════════════════════════════════════════
class TestParticle(unittest.TestCase):
"""Particle 数据结构基础测试"""
def test_default_construction(self):
"""默认构造的粒子应有正确初始值"""
p = Particle(pid=0, position=[0.1, 0.2, 0.3])
self.assertEqual(p.pid, 0)
np.testing.assert_array_almost_equal(p.position, [0.1, 0.2, 0.3])
np.testing.assert_array_equal(p.velocity, [0, 0, 0])
self.assertEqual(p.solution, {})
self.assertEqual(p.cell_id, -1)
def test_position_dtype(self):
"""粒子坐标应存储为 float32"""
p = Particle(pid=1, position=[1.0, 2.0, 3.0])
self.assertEqual(p.position.dtype, np.float32)
def test_solution_assignment(self):
"""液相化学成分字典可正确赋值"""
p = Particle(pid=2, position=[0.0, 0.0, 0.0])
p.solution = {"H": 1e-7, "Cl": 0.001}
self.assertAlmostEqual(p.solution["H"], 1e-7)
self.assertAlmostEqual(p.solution["Cl"], 0.001)
# ══════════════════════════════════════════════════════════════════════════
# 测试套件 2:ChemicalField
# ══════════════════════════════════════════════════════════════════════════
class TestChemicalField(unittest.TestCase):
"""ChemicalField 数据结构测试"""
def setUp(self):
self.cf = ChemicalField(n_chem_cells_per_dim=6)
def test_cell_count(self):
"""6×6×6 = 216 个化学单元"""
self.assertEqual(self.cf.n_cells, 216)
def test_initial_state(self):
"""初始状态:所有单元为空,体积为零"""
for cell_list in self.cf.cell_particles:
self.assertEqual(len(cell_list), 0)
np.testing.assert_array_equal(self.cf.cell_volume, np.zeros(216))
def test_assign_particle(self):
"""粒子分配应正确更新 cell_particles 和 cell_volume"""
self.cf.assign_particle(pid=0, cell_id=10)
self.cf.assign_particle(pid=1, cell_id=10)
self.assertIn(0, self.cf.cell_particles[10])
self.assertIn(1, self.cf.cell_particles[10])
self.assertAlmostEqual(
self.cf.cell_volume[10], 2 * DEFAULT_PARTICLE_VOLUME
)
def test_reset_cell_assignments(self):
"""重置后所有单元应清空"""
self.cf.assign_particle(0, 5)
self.cf.assign_particle(1, 5)
self.cf.reset_cell_assignments()
self.assertEqual(len(self.cf.cell_particles[5]), 0)
self.assertAlmostEqual(self.cf.cell_volume[5], 0.0)
def test_multiple_cells(self):
"""粒子分配到不同单元互不干扰"""
self.cf.assign_particle(0, 0)
self.cf.assign_particle(1, 100)
self.cf.assign_particle(2, 215)
self.assertEqual(len(self.cf.cell_particles[0]), 1)
self.assertEqual(len(self.cf.cell_particles[100]), 1)
self.assertEqual(len(self.cf.cell_particles[215]), 1)
# ══════════════════════════════════════════════════════════════════════════
# 测试套件 3:粒子→单元映射
# ══════════════════════════════════════════════════════════════════════════
class TestPositionToCellMapping(unittest.TestCase):
"""粒子位置到化学单元的映射测试"""
def setUp(self):
self.sim = CoupledSimulation(
mesh_system=None,
phreeqc_rm=None,
)
def test_center_particle(self):
"""域中心的粒子应映射到中心单元"""
center = np.array([[0.0, 0.0, 0.0]], dtype=np.float64)
cell_ids = self.sim._position_to_cell_id(center)
n = CHEM_GRID_DIM
expected_cell = (n // 2) + (n // 2) * n + (n // 2) * n * n
self.assertEqual(cell_ids[0], expected_cell)
def test_boundary_particles_in_range(self):
"""边界附近的粒子应映射到有效单元 ID(0 ~ n_cells-1)"""
positions = np.array(
[
[-0.8, -0.8, -0.8],
[0.79, 0.79, 0.79],
[-0.8, 0.79, -0.8],
],
dtype=np.float64,
)
cell_ids = self.sim._position_to_cell_id(positions)
n_cells = CHEM_GRID_DIM ** 3
for cid in cell_ids:
self.assertGreaterEqual(cid, 0)
self.assertLess(cid, n_cells)
def test_opposite_corners_different_cells(self):
"""域的两个对角粒子应映射到不同单元"""
corners = np.array(
[[-0.8, -0.8, -0.8], [0.79, 0.79, 0.79]], dtype=np.float64
)
cell_ids = self.sim._position_to_cell_id(corners)
self.assertNotEqual(cell_ids[0], cell_ids[1])
def test_all_particles_in_same_small_region_get_same_cell(self):
"""同一微小区域内的粒子应映射到同一单元"""
base = np.array([0.1, 0.1, 0.1])
tiny_offsets = np.random.uniform(-1e-4, 1e-4, (10, 3))
positions = (base + tiny_offsets).astype(np.float64)
cell_ids = self.sim._position_to_cell_id(positions)
self.assertTrue(np.all(cell_ids == cell_ids[0]))
def test_out_of_domain_clamped(self):
"""域外坐标应被夹持到有效单元"""
out_of_range = np.array(
[[-10.0, -10.0, -10.0], [10.0, 10.0, 10.0]], dtype=np.float64
)
cell_ids = self.sim._position_to_cell_id(out_of_range)
n_cells = CHEM_GRID_DIM ** 3
for cid in cell_ids:
self.assertGreaterEqual(cid, 0)
self.assertLess(cid, n_cells)
# ══════════════════════════════════════════════════════════════════════════
# 测试套件 4:浓度聚合与广播
# ══════════════════════════════════════════════════════════════════════════
class TestConcentrationAggregation(unittest.TestCase):
"""体积加权浓度聚合与广播测试"""
def _make_grid_positions(self, n_per_dim=6):
"""生成均匀分布的测试粒子坐标"""
coords = np.linspace(-0.7, 0.7, n_per_dim)
xx, yy, zz = np.meshgrid(coords, coords, coords)
return np.stack([xx.ravel(), yy.ravel(), zz.ravel()], axis=1).astype(
np.float64
)
def test_single_component_uniform(self):
"""均匀浓度场:聚合后各单元浓度应保持一致"""
positions = self._make_grid_positions()
n = len(positions)
n_comps = 1
conc = np.ones((n, n_comps), dtype=np.float64) * 0.5
sim = _make_coupled_sim_with_particles(
positions, n_comps=n_comps, initial_solutions=conc
)
cell_conc = sim._aggregate_cell_concentrations()
# 有粒子的单元浓度应接近 0.5
occupied = [
i for i in range(sim.chem_field.n_cells)
if sim.chem_field.cell_particles[i]
]
for cell_id in occupied:
np.testing.assert_almost_equal(cell_conc[cell_id, 0], 0.5)
def test_two_component_gradient(self):
"""
左半域浓度=1,右半域浓度=0:
聚合后左半单元应接近1,右半单元应接近0
"""
positions = self._make_grid_positions()
n = len(positions)
n_comps = 1
conc = np.zeros((n, n_comps), dtype=np.float64)
# x < 0 的粒子设浓度 = 1
left_mask = positions[:, 0] < 0.0
conc[left_mask, 0] = 1.0
sim = _make_coupled_sim_with_particles(
positions, n_comps=n_comps, initial_solutions=conc
)
cell_conc = sim._aggregate_cell_concentrations()
n_dim = CHEM_GRID_DIM
# 检查 ix=0 单元(左侧)浓度接近 1
for iy in range(n_dim):
for iz in range(n_dim):
cid = 0 + iy * n_dim + iz * n_dim * n_dim
if sim.chem_field.cell_particles[cid]:
self.assertGreater(cell_conc[cid, 0], 0.5)
def test_broadcast_restores_uniform_conc(self):
"""广播均匀单元浓度后,粒子浓度应与单元浓度一致"""
positions = self._make_grid_positions(n_per_dim=3)
n = len(positions)
n_comps = 2
conc = np.random.rand(n, n_comps)
sim = _make_coupled_sim_with_particles(
positions, n_comps=n_comps, initial_solutions=conc
)
# 用全 1 单元浓度广播
n_cells = sim.chem_field.n_cells
uniform_cell_conc = np.ones((n_cells, n_comps), dtype=np.float64)
sim._broadcast_to_particles(uniform_cell_conc)
# 所有粒子浓度应等于 1
np.testing.assert_array_almost_equal(
sim._particle_solutions[:n], np.ones((n, n_comps))
)
def test_empty_cells_contribute_zero(self):
"""空单元使用 -1.0 标记,不参与化学计算"""
# 将所有粒子集中在域左下角
positions = np.tile([-0.7, -0.7, -0.7], (10, 1)).astype(np.float64)
n_comps = 1
conc = np.ones((10, n_comps), dtype=np.float64)
sim = _make_coupled_sim_with_particles(
positions, n_comps=n_comps, initial_solutions=conc
)
cell_conc = sim._aggregate_cell_concentrations()
# 除第 0 单元外,其余空单元应标记为 -1.0
for cell_id in range(1, sim.chem_field.n_cells):
if not sim.chem_field.cell_particles[cell_id]:
np.testing.assert_array_equal(cell_conc[cell_id], -1.0)
# ══════════════════════════════════════════════════════════════════════════
# 测试套件 5:化学步控制(频率与重试)
# ══════════════════════════════════════════════════════════════════════════
class TestChemicalStepControl(unittest.TestCase):
"""化学步执行频率与 PHREEQCRM 重试机制测试"""
def _build_sim(self, chem_step_ratio=2, run_cells_side_effect=None):
n_cells = CHEM_GRID_DIM ** 3
n_comps = 1
positions = np.zeros((4, 3), dtype=np.float64)
conc = np.ones((4, n_comps), dtype=np.float64)
rm = _make_mock_rm(n_cells, n_comps)
if run_cells_side_effect is not None:
rm.RunCells.side_effect = run_cells_side_effect
sim = _make_coupled_sim_with_particles(
positions, n_comps=n_comps, initial_solutions=conc,
rm=rm, chem_step_ratio=chem_step_ratio
)
return sim, rm
def test_chemical_step_fires_at_correct_interval(self):
"""化学步应严格按 chem_step_ratio 触发"""
ratio = 3
sim, rm = self._build_sim(chem_step_ratio=ratio)
for frame in range(1, 10):
stats = sim.step()
if frame % ratio == 0:
self.assertTrue(
stats["chemical_step"],
f"第 {frame} 帧应执行化学步(ratio={ratio})",
)
else:
self.assertFalse(
stats["chemical_step"],
f"第 {frame} 帧不应执行化学步(ratio={ratio})",
)
def test_chemical_time_accumulates_correctly(self):
"""化学时间累计应等于 化学步次数 × chem_dt"""
sim, rm = self._build_sim(chem_step_ratio=1)
n_steps = 5
for _ in range(n_steps):
sim.step()
expected_time = n_steps * sim.chem_dt
self.assertAlmostEqual(sim.accumulated_chem_dt, expected_time)
def test_retry_on_failed_run_cells(self):
"""RunCells 返回非零状态时应重试,并减小 dt"""
n_cells = CHEM_GRID_DIM ** 3
n_comps = 1
# 前 CHEM_MAX_RETRIES-1 次失败,最后一次成功
call_count = [0]
def side_effect():
call_count[0] += 1
if call_count[0] < CHEM_MAX_RETRIES:
return 1 # 失败
return 0 # 成功
sim, rm = self._build_sim(
chem_step_ratio=1, run_cells_side_effect=side_effect
)
sim.step()
self.assertEqual(call_count[0], CHEM_MAX_RETRIES)
# 每次重试后 dt 应被减半
expected_dt = sim.chem_dt * (0.5 ** (CHEM_MAX_RETRIES - 1))
rm.SetTimeStep.assert_called_with(expected_dt)
def test_always_failed_run_cells_returns_pre_reaction_conc(self):
"""RunCells 始终失败时,应返回反应前浓度(不更新粒子浓度)"""
n_cells = CHEM_GRID_DIM ** 3
n_comps = 1
positions = np.zeros((4, 3), dtype=np.float64)
initial_conc = np.ones((4, n_comps), dtype=np.float64) * 2.0
rm = _make_mock_rm(n_cells, n_comps)
rm.RunCells.return_value = 1 # 始终失败
sim = _make_coupled_sim_with_particles(
positions, n_comps=n_comps, initial_solutions=initial_conc,
rm=rm, chem_step_ratio=1
)
sim.step()
# 粒子浓度应保持初始值(来自广播前的聚合输入,所有粒子在同一单元故均值=2)
expected = np.ones((4, n_comps)) * initial_conc[0, 0]
np.testing.assert_array_almost_equal(
sim._particle_solutions[:4], expected
)
def test_negative_concentration_clipped(self):
"""RunCells 产生负浓度时应截断为 0"""
n_cells = CHEM_GRID_DIM ** 3
n_comps = 2
positions = np.array([[-0.5, 0.0, 0.0], [0.5, 0.0, 0.0]], dtype=np.float64)
initial_conc = np.ones((2, n_comps), dtype=np.float64)
rm = _make_mock_rm(n_cells, n_comps)
# 返回包含负值的浓度
neg_conc = np.full((n_cells, n_comps), -0.5)
rm.GetConcentrations.return_value = neg_conc.flatten().tolist()
sim = _make_coupled_sim_with_particles(
positions, n_comps=n_comps, initial_solutions=initial_conc,
rm=rm, chem_step_ratio=1
)
sim.step()
# 所有粒子浓度应 >= 0
self.assertTrue(np.all(sim._particle_solutions[:2] >= 0.0))
def test_empty_cells_excluded_via_create_mapping(self):
"""空单元应通过 CreateMapping(-1) 从 RunCells 计算中排除"""
n_cells = CHEM_GRID_DIM ** 3
n_comps = 1
# 所有粒子集中在域的一个角落 → 绝大多数单元为空
positions = np.array([[-0.7, -0.7, -0.7]] * 4, dtype=np.float64)
conc = np.ones((4, n_comps), dtype=np.float64)
rm = _make_mock_rm(n_cells, n_comps)
sim = _make_coupled_sim_with_particles(
positions, n_comps=n_comps, initial_solutions=conc,
rm=rm, chem_step_ratio=1
)
sim.step()
# 验证 CreateMapping 被调用,且大部分单元映射为 -1
rm.CreateMapping.assert_called()
mapping = rm.CreateMapping.call_args[0][0]
inactive_count = sum(1 for m in mapping if m < 0)
self.assertGreater(inactive_count, 0, "应有空单元被排除")
# 至少有一个活跃单元(粒子所在单元)
active_count = sum(1 for m in mapping if m >= 0)
self.assertGreater(active_count, 0, "至少应有一个活跃单元")
def test_all_cells_active_when_all_have_particles(self):
"""所有单元都有粒子时,CreateMapping 应恢复完整映射"""
n_cells = CHEM_GRID_DIM ** 3
n_comps = 1
# 均匀分布粒子,每个单元都有粒子
coords = np.linspace(-0.7, 0.7, CHEM_GRID_DIM)
xx, yy, zz = np.meshgrid(coords, coords, coords)
positions = np.stack([xx.ravel(), yy.ravel(), zz.ravel()], axis=1).astype(np.float64)
n = len(positions)
conc = np.ones((n, n_comps), dtype=np.float64)
rm = _make_mock_rm(n_cells, n_comps)
sim = _make_coupled_sim_with_particles(
positions, n_comps=n_comps, initial_solutions=conc,
rm=rm, chem_step_ratio=1
)
sim.step()
# 验证 CreateMapping 使用完整映射(所有单元活跃)
rm.CreateMapping.assert_called()
mapping = rm.CreateMapping.call_args[0][0]
for i, m in enumerate(mapping):
self.assertEqual(m, i, f"单元 {i} 应映射为自身")
def test_all_empty_cells_skip_chemistry(self):
"""所有单元均为空时,应跳过化学计算"""
n_cells = CHEM_GRID_DIM ** 3
n_comps = 1
rm = _make_mock_rm(n_cells, n_comps)
sim = CoupledSimulation(
mesh_system=None,
phreeqc_rm=rm,
chem_step_ratio=1,
)
sim.n_comps = n_comps
sim.comp_names = ["tracer"]
# 无粒子 → 所有单元为空
sim.step()
# RunCells 不应被调用(所有单元为空,跳过化学计算)
rm.RunCells.assert_not_called()
# ══════════════════════════════════════════════════════════════════════════
# 测试套件 5b:CreateMapping 连续索引(空单元正确排除)
# ══════════════════════════════════════════════════════════════════════════
class TestCreateMappingConsecutive(unittest.TestCase):
"""
验证 CreateMapping 使用连续化学单元索引,确保空单元被正确排除。
旧的 identity 映射 [i if active else -1] 在活跃单元分散时会导致
大量未映射的化学单元仍被 RunCells 计算。新实现使用连续索引
(0, 1, 2, ...) 以减少化学单元数量。
"""
def test_build_consecutive_mapping_basic(self):
"""build_consecutive_mapping 应为活跃单元生成连续索引"""
# 8 cells, cells 0, 3, 7 active
exclude = [False, True, True, False, True, True, True, False]
mapping = build_consecutive_mapping(8, exclude)
self.assertEqual(mapping, [0, -1, -1, 1, -1, -1, -1, 2])
def test_build_consecutive_mapping_all_active(self):
"""所有单元活跃时应返回 identity 映射"""
exclude = [False] * 5
mapping = build_consecutive_mapping(5, exclude)
self.assertEqual(mapping, [0, 1, 2, 3, 4])
def test_build_consecutive_mapping_all_excluded(self):
"""所有单元排除时应返回全 -1"""
exclude = [True] * 4
mapping = build_consecutive_mapping(4, exclude)
self.assertEqual(mapping, [-1, -1, -1, -1])
def test_consecutive_mapping_indices_for_sparse_active_cells(self):
"""活跃单元分散时,CreateMapping 应使用连续化学单元索引(0, 1, 2, ...)"""
n_cells = CHEM_GRID_DIM ** 3
n_comps = 1
# 将粒子放在域的两个对角:确保活跃单元分散
positions = np.array(
[[-0.7, -0.7, -0.7], [0.7, 0.7, 0.7]], dtype=np.float64
)
conc = np.ones((2, n_comps), dtype=np.float64)
rm = _make_mock_rm(n_cells, n_comps)
sim = _make_coupled_sim_with_particles(
positions, n_comps=n_comps, initial_solutions=conc,
rm=rm, chem_step_ratio=1
)
sim.step()
# 验证 CreateMapping 使用连续索引
rm.CreateMapping.assert_called()
mapping = rm.CreateMapping.call_args[0][0]
# 活跃单元的化学索引应从 0 开始连续递增
active_chem_ids = sorted([m for m in mapping if m >= 0])
expected_chem_ids = list(range(len(active_chem_ids)))
self.assertEqual(
active_chem_ids, expected_chem_ids,
f"化学单元索引应为连续的 {expected_chem_ids},实际为 {active_chem_ids}",
)
def test_consecutive_mapping_reduces_chemistry_count(self):
"""连续映射应使化学单元数量 = 活跃网格单元数量"""
n_cells = CHEM_GRID_DIM ** 3
n_comps = 1
# 只在一个角落放粒子
positions = np.array([[-0.7, -0.7, -0.7]] * 4, dtype=np.float64)
conc = np.ones((4, n_comps), dtype=np.float64)
rm = _make_mock_rm(n_cells, n_comps)
sim = _make_coupled_sim_with_particles(
positions, n_comps=n_comps, initial_solutions=conc,
rm=rm, chem_step_ratio=1
)
sim.step()
mapping = rm.CreateMapping.call_args[0][0]
active_chem_ids = [m for m in mapping if m >= 0]
n_active = len(active_chem_ids)
max_chem_id = max(active_chem_ids)
# 最大化学索引应恰好等于 n_active - 1(连续索引)
self.assertEqual(
max_chem_id, n_active - 1,
f"最大化学索引应为 {n_active - 1}(连续索引),实际为 {max_chem_id}",
)
def test_mapping_preserves_grid_cell_identity(self):
"""每个活跃网格单元应映射到唯一的化学单元(无重复)"""
n_cells = CHEM_GRID_DIM ** 3
n_comps = 1
# 使用三个不同位置的粒子
positions = np.array(
[[-0.7, -0.7, -0.7], [0.0, 0.0, 0.0], [0.7, 0.7, 0.7]],
dtype=np.float64,
)
conc = np.ones((3, n_comps), dtype=np.float64)
rm = _make_mock_rm(n_cells, n_comps)
sim = _make_coupled_sim_with_particles(
positions, n_comps=n_comps, initial_solutions=conc,
rm=rm, chem_step_ratio=1
)
sim.step()
mapping = rm.CreateMapping.call_args[0][0]
active_chem_ids = [m for m in mapping if m >= 0]
# 化学单元索引应无重复
self.assertEqual(
len(active_chem_ids),
len(set(active_chem_ids)),
"化学单元索引不应有重复",
)
def test_full_mapping_still_identity(self):
"""所有单元都有粒子时,映射应仍为 0, 1, 2, ..., n-1(连续 = identity)"""
n_cells = CHEM_GRID_DIM ** 3
n_comps = 1
coords = np.linspace(-0.7, 0.7, CHEM_GRID_DIM)
xx, yy, zz = np.meshgrid(coords, coords, coords)
positions = np.stack(
[xx.ravel(), yy.ravel(), zz.ravel()], axis=1
).astype(np.float64)
n = len(positions)
conc = np.ones((n, n_comps), dtype=np.float64)
rm = _make_mock_rm(n_cells, n_comps)
sim = _make_coupled_sim_with_particles(
positions, n_comps=n_comps, initial_solutions=conc,
rm=rm, chem_step_ratio=1
)
sim.step()
mapping = rm.CreateMapping.call_args[0][0]
for i, m in enumerate(mapping):
self.assertEqual(m, i, f"完全活跃时,单元 {i} 应映射为 {i}")
def test_run_cells_called_with_valid_mapping(self):
"""即使大部分单元为空,RunCells 仍应被调用且成功"""
n_cells = CHEM_GRID_DIM ** 3
n_comps = 2
# 只有一个角落有粒子
positions = np.array(
[[-0.7, -0.7, -0.7], [-0.6, -0.6, -0.6]], dtype=np.float64
)
conc = np.ones((2, n_comps), dtype=np.float64) * 0.01
rm = _make_mock_rm(n_cells, n_comps)
sim = _make_coupled_sim_with_particles(
positions, n_comps=n_comps, initial_solutions=conc,
rm=rm, chem_step_ratio=1
)
sim.step()
# RunCells 应被调用(至少有一个活跃单元)
rm.RunCells.assert_called_once()
def test_mapping_changes_between_frames(self):
"""粒子位置变化后,映射应随之更新"""
n_cells = CHEM_GRID_DIM ** 3
n_comps = 1
# 初始位置:只在一个角落
positions = np.array(
[[-0.7, -0.7, -0.7]], dtype=np.float64
)
conc = np.ones((1, n_comps), dtype=np.float64)
rm = _make_mock_rm(n_cells, n_comps)
sim = _make_coupled_sim_with_particles(
positions, n_comps=n_comps, initial_solutions=conc,
rm=rm, chem_step_ratio=1
)
# 第一帧
sim.step()
mapping1 = rm.CreateMapping.call_args[0][0][:]
# 移动粒子到另一个位置(直接修改内部状态以模拟位置变化)
sim._particle_solutions[0] = [1.0]
sim._particle_cell_ids[0] = 0
# 第二帧
sim.step()
mapping2 = rm.CreateMapping.call_args[0][0][:]
# 两帧的映射都应有效(活跃索引连续且无间隙)
for mapping in [mapping1, mapping2]:
active_ids = sorted([m for m in mapping if m >= 0])
expected = list(range(len(active_ids)))
self.assertEqual(active_ids, expected)
# ══════════════════════════════════════════════════════════════════════════
# 测试套件 6:场景测试 – 酸碱中和(HCl + NaOH)
# ══════════════════════════════════════════════════════════════════════════
class TestAcidBaseNeutralization(unittest.TestCase):
"""
场景:酸碱中和(HCl + NaOH)
预期现象:
- pH 梯度从界面平滑扩散
- 每一帧化学步后,pH 场不引入剧烈的数值振荡
- 质量守恒:各组分总量在化学步前后变化有限
"""
def _setup_acid_base_sim(self, n_per_side=3):
"""
设置酸碱中和测试:
- 左半域(x < 0):酸性,H+ 浓度 = 0.01 mol/L
- 右半域(x >= 0):碱性,OH- 浓度 = 0.01 mol/L
"""
# 组分:0=H+(酸性指标),1=Na(碱性指标)
n_comps = 2
n_cells = CHEM_GRID_DIM ** 3
# 创建粒子:左右各半
coords = np.linspace(-0.7, 0.7, n_per_side)
xs, ys, zs = np.meshgrid(coords, coords, coords)
positions = np.stack([xs.ravel(), ys.ravel(), zs.ravel()], axis=1)
n = len(positions)
# 初始浓度:左侧 H+=0.01,右侧 Na=0.01
conc = np.zeros((n, n_comps), dtype=np.float64)
left = positions[:, 0] < 0.0
right = ~left
conc[left, 0] = 0.01 # H+
conc[right, 1] = 0.01 # Na+
# 模拟 PHREEQCRM 的行为:
# 中和反应后,界面附近的 H+ 和 Na+ 都减少
def neutralization_reaction(cell_conc_flat):
"""模拟中和:每单元 H+ 减去 min(H+, Na+),Na+ 同理"""
cc = np.array(cell_conc_flat).reshape((n_cells, n_comps))
neutralized = cc.copy()
delta = np.minimum(cc[:, 0], cc[:, 1])
neutralized[:, 0] -= delta
neutralized[:, 1] -= delta
return neutralized.flatten().tolist()
rm = _make_mock_rm(n_cells, n_comps)
rm.GetConcentrations.side_effect = lambda: neutralization_reaction(
rm.SetConcentrations.call_args[0][0]
)
sim = _make_coupled_sim_with_particles(
positions, n_comps=n_comps, initial_solutions=conc,
rm=rm, chem_step_ratio=1
)
return sim, positions, conc
def test_ph_gradient_smooth_propagation(self):
"""
执行多帧后,酸性/碱性浓度梯度应平滑(无剧烈振荡)。
验证:相邻化学单元浓度差不超过初始最大浓度值。
"""
sim, positions, init_conc = self._setup_acid_base_sim()
n_frames = 5
for _ in range(n_frames):
sim.step()
cell_conc = sim._aggregate_cell_concentrations()
max_initial_conc = 0.01
# 检查相邻单元浓度差(沿 x 轴)
n = CHEM_GRID_DIM
for iy in range(n):
for iz in range(n):
for ix in range(n - 1):
cid1 = ix + iy * n + iz * n * n
cid2 = (ix + 1) + iy * n + iz * n * n
diff = np.abs(cell_conc[cid1] - cell_conc[cid2])
self.assertTrue(
np.all(diff <= max_initial_conc + 1e-10),
f"相邻单元浓度差过大: cell {cid1} vs {cid2}, diff={diff}",
)
def test_mass_conservation_after_chemical_step(self):
"""
质量守恒:化学步后总质量应 ≤ 化学步前(中和消耗物质)。
验证点:组分总质量不增加。
"""
sim, positions, init_conc = self._setup_acid_base_sim()
# 记录初始总质量
total_before = init_conc.sum(axis=0)
# 执行化学步
sim.step()
n = sim.mesh_system.current_particle_num
total_after = sim._particle_solutions[:n].sum(axis=0)
# 质量应守恒或减少(中和消耗)
for comp in range(2):
self.assertLessEqual(
total_after[comp],
total_before[comp] + 1e-9,
f"组分 {comp} 质量不守恒:before={total_before[comp]:.6f}, "
f"after={total_after[comp]:.6f}",
)
def test_no_negative_concentrations_after_neutralization(self):
"""中和反应后所有粒子浓度应 >= 0"""
sim, _, _ = self._setup_acid_base_sim()
for _ in range(3):
sim.step()
n = sim.mesh_system.current_particle_num
self.assertTrue(
np.all(sim._particle_solutions[:n] >= 0.0),
"检测到负浓度",
)
# ══════════════════════════════════════════════════════════════════════════
# 测试套件 7:场景测试 – 碳酸盐平衡(CO₂-H₂O)
# ══════════════════════════════════════════════════════════════════════════
class TestCarbonateEquilibrium(unittest.TestCase):
"""
场景:碳酸盐平衡(CO₂-H₂O)
预期现象:
- pH 随 CO₂ 分压升高而降低
- PHREEQCRM 与物理时间步正确协调(每 N 帧化学步)
验证点:
- 高 CO₂ 分压 → 较低 pH(较高 H+ 浓度)
- 化学步累计时间与帧数/频率一致
"""
def _make_co2_sim(self, co2_concentration: float, chem_step_ratio: int = 2):
"""
构建 CO₂-H₂O 系统测试环境。
Args:
co2_concentration: CO₂ 初始浓度(mol/L),模拟 pH 效应
"""
n_cells = CHEM_GRID_DIM ** 3
n_comps = 2 # comp0=CO₂, comp1=H+(pH 指示)
positions = np.zeros((6, 3), dtype=np.float64)
for i in range(6):
positions[i] = [i * 0.2 - 0.5, 0.0, 0.0]
conc = np.zeros((6, n_comps), dtype=np.float64)
conc[:, 0] = co2_concentration # 均匀 CO₂
# CO₂ 溶解产生 H+:pH 降低 → H+ 浓度增大
# 模拟碳酸解离平衡:H+ = sqrt(K_a * CO₂),K_a=4.3e-7(H₂CO₃ ⇌ H+ + HCO₃⁻)
K_a = 4.3e-7
def co2_ph_reaction(cell_conc_flat):
cc = np.array(cell_conc_flat).reshape((n_cells, n_comps))
new_cc = cc.copy()
new_cc[:, 1] = np.sqrt(K_a * np.maximum(cc[:, 0], 0.0))
return new_cc.flatten().tolist()
rm = _make_mock_rm(n_cells, n_comps)
rm.GetConcentrations.side_effect = lambda: co2_ph_reaction(
rm.SetConcentrations.call_args[0][0]
)
sim = _make_coupled_sim_with_particles(
positions, n_comps=n_comps, initial_solutions=conc,
rm=rm, chem_step_ratio=chem_step_ratio
)
return sim, K_a
def test_ph_decreases_with_co2(self):
"""高 CO₂ 浓度应产生更高的 H+ 浓度(更低 pH)"""
# 使用 chem_step_ratio=1 确保第一帧即触发化学步
sim_low, K_a = self._make_co2_sim(co2_concentration=1e-4, chem_step_ratio=1)
sim_high, _ = self._make_co2_sim(co2_concentration=1e-2, chem_step_ratio=1)
sim_low.step()
sim_high.step()
n = sim_low.mesh_system.current_particle_num
h_low = sim_low._particle_solutions[:n, 1].mean()
h_high = sim_high._particle_solutions[:n, 1].mean()
self.assertGreater(
h_high, h_low,
"高 CO₂ 浓度应产生更高的 H+ 浓度",
)
def test_chemical_step_coordination(self):
"""
化学步应严格与物理步协调(每 N 帧执行一次)。
验证:累计化学时间 = 化学步次数 × chem_dt
"""
ratio = 3
sim, _ = self._make_co2_sim(co2_concentration=1e-3, chem_step_ratio=ratio)
n_frames = 9 # 3 次化学步
for _ in range(n_frames):
sim.step()
expected_chem_steps = n_frames // ratio
expected_chem_time = expected_chem_steps * sim.chem_dt
self.assertAlmostEqual(sim.accumulated_chem_dt, expected_chem_time)
def test_phreeqcrm_called_only_on_chemical_frames(self):
"""PHREEQCRM.RunCells() 仅在化学步帧被调用"""
ratio = 4
sim, _ = self._make_co2_sim(co2_concentration=1e-3, chem_step_ratio=ratio)
n_frames = 8
for _ in range(n_frames):
sim.step()
expected_calls = n_frames // ratio
self.assertEqual(sim.rm.RunCells.call_count, expected_calls)
# ══════════════════════════════════════════════════════════════════════════
# 测试套件 8:场景测试 – 对流-反应(剪切流)
# ══════════════════════════════════════════════════════════════════════════
class TestAdvectionReaction(unittest.TestCase):
"""
场景:对流-反应(剪切流)
预期现象:
- 反应前沿被剪切流拉伸变形
- 平流过程不引起数值振荡
验证点:
- 粒子移动后浓度场光滑(无突变)
- 移动前后总质量误差 < 1%(无扩散引起的数值耗散)