sawsim 2.0.1__py3-none-any.whl

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.
Files changed (63) hide show
  1. sawsim/__init__.py +9 -0
  2. sawsim/api.py +90 -0
  3. sawsim/cli.py +48 -0
  4. sawsim/config.py +150 -0
  5. sawsim/frequency_tasks.py +55 -0
  6. sawsim/hex_models.py +94 -0
  7. sawsim/hex_solver.py +125 -0
  8. sawsim/material_catalog.py +49 -0
  9. sawsim/material_library/__init__.py +111 -0
  10. sawsim/material_library/__main__.py +24 -0
  11. sawsim/material_library/builtin/al__1.0.0.json +116 -0
  12. sawsim/material_library/builtin/al_hex35__1.0.0.json +116 -0
  13. sawsim/material_library/builtin/cu__1.0.0.json +116 -0
  14. sawsim/material_library/builtin/linbo3_bouchy_2022__1.0.0.json +116 -0
  15. sawsim/material_library/builtin/linbo3_tc__1.0.0.json +116 -0
  16. sawsim/material_library/builtin/polysi_160__1.0.0.json +116 -0
  17. sawsim/material_library/builtin/polysi__1.0.0.json +116 -0
  18. sawsim/material_library/builtin/si_aniso_hex__1.0.0.json +116 -0
  19. sawsim/material_library/builtin/si_cubic_hex__1.0.0.json +116 -0
  20. sawsim/material_library/builtin/si_isotropic__1.0.0.json +116 -0
  21. sawsim/material_library/builtin/sin__1.0.0.json +116 -0
  22. sawsim/material_library/builtin/sio2__1.0.0.json +116 -0
  23. sawsim/material_library/builtin/sp_baseline__1.0.0.json +116 -0
  24. sawsim/material_library/custom/.gitkeep +0 -0
  25. sawsim/material_library/schema.py +88 -0
  26. sawsim/material_runtime.py +44 -0
  27. sawsim/mesh_sp.py +134 -0
  28. sawsim/mesh_view_data.py +41 -0
  29. sawsim/models.py +140 -0
  30. sawsim/runtime.py +31 -0
  31. sawsim/saw2d/__init__.py +3 -0
  32. sawsim/saw2d/gmsh_io.py +160 -0
  33. sawsim/saw2d/materials.py +316 -0
  34. sawsim/saw2d/mmesh.py +64 -0
  35. sawsim/saw2d/piezo_fem.py +405 -0
  36. sawsim/saw2d/saw3d/__init__.py +43 -0
  37. sawsim/saw2d/saw3d/assembly.py +327 -0
  38. sawsim/saw2d/saw3d/bc_solve.py +350 -0
  39. sawsim/saw2d/saw3d/boundary.py +233 -0
  40. sawsim/saw2d/saw3d/elements.py +105 -0
  41. sawsim/saw2d/saw3d/gmsh_io.py +199 -0
  42. sawsim/saw2d/saw3d/mesh_io.py +66 -0
  43. sawsim/saw2d/sweep.py +378 -0
  44. sawsim/saw2d/viz.py +121 -0
  45. sawsim/solver.py +165 -0
  46. sawsim/sp_hex_meshes/__init__.py +23 -0
  47. sawsim/sp_hex_meshes/double.py +140 -0
  48. sawsim/sp_hex_meshes/quad.py +147 -0
  49. sawsim/sp_hex_meshes/single.py +132 -0
  50. sawsim/sp_hex_meshes/triple.py +131 -0
  51. sawsim/sp_meshes/__init__.py +36 -0
  52. sawsim/sp_meshes/double.py +193 -0
  53. sawsim/sp_meshes/quad.py +180 -0
  54. sawsim/sp_meshes/tcsaw.py +264 -0
  55. sawsim/sp_meshes/triple.py +150 -0
  56. sawsim/sp_specs.py +68 -0
  57. sawsim-2.0.1.dist-info/LICENSE +661 -0
  58. sawsim-2.0.1.dist-info/LICENSE.md +6 -0
  59. sawsim-2.0.1.dist-info/METADATA +116 -0
  60. sawsim-2.0.1.dist-info/RECORD +63 -0
  61. sawsim-2.0.1.dist-info/WHEEL +5 -0
  62. sawsim-2.0.1.dist-info/entry_points.txt +2 -0
  63. sawsim-2.0.1.dist-info/top_level.txt +1 -0
sawsim/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """SawSim: piezoelectric coupled FEM for SAW resonator unit cells."""
2
+ __version__ = "2.0.1"
3
+
4
+ from sawsim import runtime as _runtime # noqa: F401,E402 (GL shims before gmsh is imported anywhere)
5
+
6
+ from sawsim.config import SimulationConfig # noqa: E402
7
+ from sawsim.api import Model, sweep, Result # noqa: E402
8
+
9
+ __all__ = ["SimulationConfig", "Model", "sweep", "Result", "__version__"]
sawsim/api.py ADDED
@@ -0,0 +1,90 @@
1
+ """Public Python API: Model -> sweep -> Result."""
2
+ from __future__ import annotations
3
+ import json, shutil, tempfile
4
+ from pathlib import Path
5
+ from typing import Callable, Optional
6
+ import numpy as np
7
+
8
+ from sawsim.config import SimulationConfig
9
+ from sawsim.sp_specs import MODEL_SPECS
10
+
11
+
12
+ class Model:
13
+ """A unit-cell model: template id plus any SimulationConfig fields.
14
+
15
+ >>> m = Model("sp_double_layer", pitch_um=1.085, points=101)
16
+ """
17
+ def __init__(self, model_id: str = "sp_single_layer", **params):
18
+ self.config = SimulationConfig.model_validate(dict(params, model_id=model_id))
19
+
20
+ @classmethod
21
+ def from_json(cls, path) -> "Model":
22
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
23
+ return cls(**data)
24
+
25
+ @staticmethod
26
+ def templates() -> dict:
27
+ return {k: v["name"] for k, v in MODEL_SPECS.items()}
28
+
29
+ def to_dict(self) -> dict:
30
+ return self.config.model_dump()
31
+
32
+ def __repr__(self):
33
+ c = self.config
34
+ return f"Model({c.model_id!r}, pitch_um={c.pitch_um}, points={c.points}, {c.start_ghz}-{c.stop_ghz} GHz)"
35
+
36
+
37
+ class Result:
38
+ """Results of one sweep, backed by an output directory (curve.json, metadata.json, images, npz)."""
39
+ def __init__(self, output_dir):
40
+ self.dir = Path(output_dir)
41
+ self.manifest = self._load("manifest.json")
42
+ self.metadata = self._load("metadata.json")
43
+ self.curve = self._load("curve.json")
44
+
45
+ def _load(self, name):
46
+ p = self.dir / name
47
+ return json.loads(p.read_text(encoding="utf-8")) if p.exists() else {}
48
+
49
+ @property
50
+ def frequency_ghz(self) -> np.ndarray:
51
+ return np.asarray(self.curve.get("frequency_ghz", []), dtype=float)
52
+
53
+ @property
54
+ def admittance(self) -> np.ndarray:
55
+ return np.asarray(self.curve.get("real", []), dtype=float) + 1j * np.asarray(self.curve.get("imag", []), dtype=float)
56
+
57
+ @property
58
+ def magnitude(self) -> np.ndarray:
59
+ return np.abs(self.admittance)
60
+
61
+ @property
62
+ def peak_frequency_ghz(self) -> Optional[float]:
63
+ return self.metadata.get("peak_frequency_ghz")
64
+
65
+ def artifact(self, name: str) -> Path:
66
+ return self.dir / name
67
+
68
+ def save(self, target) -> Path:
69
+ """Copy the whole result directory to `target` (created if needed)."""
70
+ target = Path(target)
71
+ if target.resolve() != self.dir.resolve():
72
+ shutil.copytree(self.dir, target, dirs_exist_ok=True)
73
+ return target
74
+
75
+ def __repr__(self):
76
+ return f"Result({self.dir}, {len(self.frequency_ghz)} points, peak={self.peak_frequency_ghz} GHz)"
77
+
78
+
79
+ def sweep(model: Model | SimulationConfig | dict, output_dir=None, *, mesh_only: bool = False,
80
+ on_progress: Optional[Callable] = None) -> Result:
81
+ """Run the frequency sweep (or only build the mesh) and return a Result.
82
+
83
+ If output_dir is None a fresh temporary directory is used.
84
+ """
85
+ from sawsim.solver import run_simulation
86
+ cfg = model.config if isinstance(model, Model) else model
87
+ out = Path(output_dir) if output_dir else Path(tempfile.mkdtemp(prefix="sawsim_"))
88
+ out.mkdir(parents=True, exist_ok=True)
89
+ run_simulation(cfg, str(out), on_progress=on_progress, mesh_only=mesh_only)
90
+ return Result(out)
sawsim/cli.py ADDED
@@ -0,0 +1,48 @@
1
+ """Command-line interface: sawsim run | templates | serve."""
2
+ import argparse, json, sys
3
+ from pathlib import Path
4
+
5
+
6
+ def cmd_run(args):
7
+ from sawsim.api import Model, sweep
8
+ data = json.loads(Path(args.config).read_text(encoding="utf-8"))
9
+ model = Model(**data)
10
+ def progress(ev):
11
+ if args.quiet: return
12
+ print(f"\r{ev.get('stage','')} {ev.get('completed',0)}/{ev.get('total',0)} {ev.get('elapsed_seconds',0):.1f}s", end="", file=sys.stderr, flush=True)
13
+ res = sweep(model, args.output, mesh_only=args.mesh_only, on_progress=progress)
14
+ if not args.quiet: print(file=sys.stderr)
15
+ print(f"output: {res.dir}")
16
+ if not args.mesh_only:
17
+ print(f"points: {len(res.frequency_ghz)} peak: {res.peak_frequency_ghz} GHz |Y|max: {res.magnitude.max():.4g}")
18
+ return 0
19
+
20
+
21
+ def cmd_templates(args):
22
+ from sawsim.api import Model
23
+ for k, v in Model.templates().items():
24
+ print(f"{k:24s} {v}")
25
+ return 0
26
+
27
+
28
+ def cmd_serve(args):
29
+ print("`sawsim serve` (local web UI) ships with the web extra in a later step; run the API with:\n"
30
+ " python -m uvicorn sawsim_web.app:app --port 8765", file=sys.stderr)
31
+ return 2
32
+
33
+
34
+ def main(argv=None):
35
+ p = argparse.ArgumentParser(prog="sawsim", description="SawSim unit-cell piezoelectric FEM")
36
+ sub = p.add_subparsers(dest="cmd", required=True)
37
+ r = sub.add_parser("run", help="run a sweep from a JSON config")
38
+ r.add_argument("config"); r.add_argument("-o", "--output", required=True)
39
+ r.add_argument("--mesh-only", action="store_true"); r.add_argument("-q", "--quiet", action="store_true")
40
+ r.set_defaults(fn=cmd_run)
41
+ t = sub.add_parser("templates", help="list model templates"); t.set_defaults(fn=cmd_templates)
42
+ s = sub.add_parser("serve", help="start the local web UI"); s.set_defaults(fn=cmd_serve)
43
+ args = p.parse_args(argv)
44
+ return args.fn(args)
45
+
46
+
47
+ if __name__ == "__main__":
48
+ sys.exit(main())
sawsim/config.py ADDED
@@ -0,0 +1,150 @@
1
+ """Validated SP family inputs; lengths in µm, frequency in GHz, angles in degrees."""
2
+ from copy import deepcopy
3
+ from typing import Literal, Optional, List, Dict
4
+ from pydantic import BaseModel, Field, model_validator, ConfigDict
5
+
6
+
7
+ class LayerConfig(BaseModel):
8
+ model_config = ConfigDict(extra='forbid', allow_inf_nan=False)
9
+ material_id: str
10
+ thickness_um: float = Field(gt=0, le=30)
11
+ euler_phi_deg: float = Field(0.0, ge=-360, le=360)
12
+ euler_theta_deg: float = Field(0.0, ge=-360, le=360)
13
+ euler_psi_deg: float = Field(0.0, ge=-360, le=360)
14
+
15
+
16
+ class SimulationConfig(BaseModel):
17
+ model_config = ConfigDict(extra='forbid', allow_inf_nan=False)
18
+ model_id: str = 'sp_single_layer'
19
+ mode_extension: int = Field(1, ge=0, le=1, strict=True)
20
+ substrate_material: str = 'sp_baseline'
21
+ electrode_material: str = 'al'
22
+ layers: List[LayerConfig] = Field(default_factory=list)
23
+ material_snapshots: Dict[str, dict] = Field(default_factory=dict, max_length=12)
24
+ # phi/theta/psi are intrinsic ZXZ alpha/beta/gamma, in degrees. Convention id 'zxz'; legacy ids accepted as aliases.
25
+ euler_convention: Literal['zxz'] = 'zxz'
26
+ euler_phi_deg: float = Field(0.0, ge=-360, le=360)
27
+ euler_theta_deg: float = Field(-42.0, ge=-360, le=360)
28
+ euler_psi_deg: float = Field(0.0, ge=-360, le=360)
29
+ aperture_um: Optional[float] = Field(None, gt=0)
30
+ pitch_um: float = Field(1.085, gt=0)
31
+ electrode_um: float = Field(0.17, gt=0)
32
+ substrate_um: float = Field(8, gt=0)
33
+ metal_ratio: float = Field(0.5, gt=0, lt=1)
34
+ mesh_um: float = Field(0.5, gt=0)
35
+ start_ghz: float = Field(1.5, gt=0)
36
+ stop_ghz: float = Field(2.7, gt=0)
37
+ points: int = Field(41, ge=3, le=1201, strict=True)
38
+ voltage: float = Field(1.0, gt=0)
39
+
40
+ @model_validator(mode='before')
41
+ @classmethod
42
+ def model_defaults(cls, data):
43
+ if not isinstance(data, dict):
44
+ return data
45
+ from sawsim.sp_specs import MODEL_SPECS
46
+ model_id = data.get('model_id','sp_single_layer')
47
+ if not isinstance(model_id,str) or model_id not in MODEL_SPECS:
48
+ raise ValueError('未知模型: ' + str(model_id))
49
+ defaults = deepcopy(MODEL_SPECS[model_id]['defaults'])
50
+ if data.get('euler_convention') in ('reference_zxz', 'zxz_intrinsic'):
51
+ data = dict(data, euler_convention='zxz')
52
+ if data.get('euler_convention') == 'legacy_zxz':
53
+ keys = ('euler_phi_deg', 'euler_theta_deg', 'euler_psi_deg')
54
+ old_defaults = dict(zip(keys, [-defaults[k] for k in reversed(keys)]))
55
+ try:
56
+ angles = [float(data.get(k, old_defaults[k])) for k in keys]
57
+ except (TypeError, ValueError):
58
+ raise ValueError('旧欧拉角必须为有限数值,单位度')
59
+ data = dict(data, **dict(zip(keys, [-v for v in reversed(angles)])))
60
+ data['euler_convention'] = 'zxz'
61
+ return dict(defaults, **data)
62
+
63
+ @model_validator(mode='after')
64
+ def validate_model(self):
65
+ from sawsim.sp_specs import MODEL_SPECS
66
+ from sawsim.material_library import get_record
67
+ from sawsim.material_library.schema import MaterialRecord
68
+ references=[(self.substrate_material,'substrate'),(self.electrode_material,'electrode')]
69
+ references += [(layer.material_id,'layer') for layer in self.layers]
70
+ used={mid for mid,role in references}
71
+ if set(self.material_snapshots)-used:
72
+ raise ValueError('材料快照包含未使用的材料 ID')
73
+ snapshots={}
74
+ for mid,role in references:
75
+ try:
76
+ record=MaterialRecord.model_validate(self.material_snapshots[mid] if mid in self.material_snapshots else get_record(mid)).model_dump()
77
+ except KeyError:
78
+ raise ValueError('未知材料: '+mid)
79
+ if record['id']!=mid or role not in record['roles']:
80
+ raise ValueError('材料 ID 或适用区域不匹配: '+mid)
81
+ snapshots[mid]=record
82
+ self.material_snapshots=snapshots
83
+ spec=MODEL_SPECS[self.model_id]
84
+ if self.model_id.startswith('sp_2p5d_'):
85
+ if self.aperture_um is None:
86
+ raise ValueError('2.5D 薄片模型需要 aperture_um')
87
+ elif self.aperture_um is not None:
88
+ raise ValueError('aperture_um 仅适用于 2.5D Hex27 薄片模型')
89
+ if self.mode_extension not in spec['supported_mode_extensions']:
90
+ raise ValueError('该模型不支持所选 ME 模式')
91
+ if len(self.layers)!=spec['layer_count']:
92
+ raise ValueError('该模型需要 %d 个附加层' % spec['layer_count'])
93
+ for layer in self.layers:
94
+ lower=spec.get('layer_thickness_min_um',0.01)
95
+ upper=spec.get('layer_thickness_max_um',20.0)
96
+ if not lower<=layer.thickness_um<=upper:
97
+ raise ValueError('附加层厚度超出允许范围 [%s,%s] µm' % (lower,upper))
98
+ for field in spec['numeric_fields']:
99
+ value=getattr(self,field['key'])
100
+ if not field['min']<=value<=field['max']:
101
+ raise ValueError('%s 超出允许范围 [%s,%s]' % (field['key'],field['min'],field['max']))
102
+ if self.model_id=='sp_tcsaw' and self.layers[0].thickness_um<=self.electrode_um:
103
+ raise ValueError('TC 内侧包覆层厚度必须大于电极厚度')
104
+ if self.stop_ghz<=self.start_ghz:
105
+ raise ValueError('结束频率必须大于起始频率')
106
+ return self
107
+
108
+
109
+ def config_from_saved(data):
110
+ """Import saved tasks: pre-0.6 inputs without a marker use legacy ZXZ.
111
+
112
+ New unmarked API/CLI inputs use the current ZXZ convention; use this only for historical imports.
113
+ """
114
+ cfg=SimulationConfig.model_validate(dict(
115
+ data, euler_convention=data.get('euler_convention', 'legacy_zxz')))
116
+ if not data.get('material_snapshots'):
117
+ # Pre-library tasks used the initial immutable historical datasets.
118
+ from sawsim.material_library import get_record
119
+ for mid,record in list(cfg.material_snapshots.items()):
120
+ if record['status']=='legacy':
121
+ cfg.material_snapshots[mid]=get_record(mid,'1.0.0')
122
+ return cfg
123
+
124
+
125
+ Config=SimulationConfig
126
+
127
+
128
+ class ProgressEvent(BaseModel):
129
+ stage: str
130
+ stage_id: str
131
+ completed: int = Field(ge=0)
132
+ total: int = Field(ge=1)
133
+ elapsed_seconds: float = Field(ge=0)
134
+ frequency_hz: Optional[float] = None
135
+
136
+
137
+ class Artifact(BaseModel):
138
+ name: str
139
+ kind: str
140
+ media_type: str
141
+
142
+
143
+ class ResultManifest(BaseModel):
144
+ schema_version: Literal['1.0'] = '1.0'
145
+ model_id: str
146
+ artifacts: List[Artifact]
147
+
148
+
149
+ from sawsim.models import list_models
150
+ MODEL_TEMPLATES=list_models()
@@ -0,0 +1,55 @@
1
+ """Bounded, ordered frequency solves; existing FEM engines remain unchanged."""
2
+ from contextlib import contextmanager
3
+ import multiprocessing as mp
4
+ import os
5
+ import numpy as np
6
+
7
+ _engine = None
8
+ _voltage = None
9
+ _hex = False
10
+
11
+
12
+ def worker_count(points):
13
+ configured = os.environ.get('SAW_SWEEP_WORKERS')
14
+ workers = int(configured) if configured is not None else (8 if points > 41 else 1)
15
+ if not 1 <= workers <= 8:
16
+ raise ValueError('SAW_SWEEP_WORKERS must be an integer from 1 to 8')
17
+ if 'fork' not in mp.get_all_start_methods():
18
+ return 1
19
+ return min(workers, points, os.cpu_count() or 1)
20
+
21
+
22
+ def _initialize(engine, voltage, is_hex):
23
+ global _engine, _voltage, _hex
24
+ _engine, _voltage, _hex = engine, voltage, is_hex
25
+
26
+
27
+ def _charge(frequency):
28
+ kwargs = {'voltage': _voltage} if _hex else {'V': _voltage}
29
+ return complex(_engine.solve(2*np.pi*frequency, **kwargs)[1])
30
+
31
+
32
+ @contextmanager
33
+ def charge_stream(engine, frequencies, voltage, *, is_hex=False):
34
+ """Yield charges in frequency order; clean up workers on failure/cancellation.
35
+
36
+ Fork workers inherit the already assembled read-only matrices. Each engine's
37
+ existing backend handles its process-local factorization. Only scalar
38
+ charges return to the parent; full fields are solved once at the peak.
39
+ """
40
+ count = worker_count(len(frequencies))
41
+ if count == 1:
42
+ kwargs = {'voltage': voltage} if is_hex else {'V': voltage}
43
+ yield (complex(engine.solve(2*np.pi*f, **kwargs)[1]) for f in frequencies)
44
+ return
45
+ pool = mp.get_context('fork').Pool(count, initializer=_initialize,
46
+ initargs=(engine, voltage, is_hex))
47
+ try:
48
+ yield pool.imap(_charge, frequencies, chunksize=1)
49
+ except BaseException:
50
+ pool.terminate()
51
+ raise
52
+ else:
53
+ pool.close()
54
+ finally:
55
+ pool.join()
sawsim/hex_models.py ADDED
@@ -0,0 +1,94 @@
1
+ """Original Hex27 periodic thin-slice builders, adapted to project configurations."""
2
+ from dataclasses import dataclass
3
+ import numpy as np
4
+ from sawsim import runtime
5
+ import gmsh
6
+ from sawsim.saw2d import materials
7
+ from sawsim.saw2d.saw3d import harvest_3d_mesh,find_saw_boundary_3ds,boundary_idx_vect_3ds
8
+ from sawsim.sp_hex_meshes import build_mesh
9
+
10
+
11
+ @dataclass
12
+ class HexModel:
13
+ mesh: object
14
+ position_m: np.ndarray
15
+ material_type: list
16
+ pml: list
17
+ boundary: object
18
+ material_data: dict
19
+ details: dict
20
+ preview_quads: np.ndarray
21
+ preview_tags: np.ndarray
22
+
23
+
24
+ def _front_faces(mesh,position):
25
+ y0=float(position[:,1].min())
26
+ on_front=np.isclose(position[:,1],y0,rtol=0,atol=1e-14)
27
+ owners={}
28
+ for hexa,tag in zip(mesh.hexas27,mesh.hex_tag):
29
+ nodes=hexa[on_front[hexa]]
30
+ if len(nodes)==9:owners[frozenset(nodes.tolist())]=int(tag)
31
+ faces=[];tags=[];seen=set()
32
+ for face in mesh.quads9:
33
+ key=frozenset(face.tolist())
34
+ if key in owners and key not in seen:
35
+ faces.append(face);tags.append(owners[key]);seen.add(key)
36
+ if len(faces)!=len(owners) or not faces:raise ValueError('Cannot recover complete y=0 front section from Hex27 mesh')
37
+ return np.array(faces,dtype=int),np.array(tags,dtype=int)
38
+
39
+
40
+ def _check_pair(sets,position,a,b,axis,span):
41
+ left=sets[a];right=sets[b];other=[i for i in range(3) if i!=axis]
42
+ def ordered(nodes):
43
+ coordinates=np.round(position[nodes][:,other],14)
44
+ return nodes[np.lexsort((coordinates[:,1],coordinates[:,0]))]
45
+ left=ordered(left);right=ordered(right)
46
+ if len(left)!=len(right) or not np.allclose(position[left][:,other],position[right][:,other],rtol=0,atol=1e-13):
47
+ raise ValueError('Hex27 periodic boundary coordinates do not match: '+a+'/'+b)
48
+ if not np.allclose(np.abs(position[right,axis]-position[left,axis]),span,rtol=0,atol=1e-13):
49
+ raise ValueError('Hex27 periodic span does not match geometry: '+a+'/'+b)
50
+
51
+
52
+ def build_hex_model(config):
53
+ try:
54
+ info=build_mesh(config)
55
+ if set(int(t) for t in gmsh.model.mesh.getElementTypes(3))!={12}:
56
+ raise ValueError('2.5D thin-slice model requires only Hex27 volume elements')
57
+ mesh=harvest_3d_mesh()
58
+ finally:
59
+ if gmsh.isInitialized():gmsh.finalize()
60
+ position=mesh.pos*1e-6
61
+ sets=find_saw_boundary_3ds(mesh)
62
+ _check_pair(sets,position,'L','R',0,info['period_m'])
63
+ _check_pair(sets,position,'front','back',1,info['aperture_m'])
64
+ bottom=float(position[sets['bottom'],2].min())
65
+ angles=[config.euler_phi_deg,config.euler_theta_deg,config.euler_psi_deg]
66
+ from sawsim.material_runtime import resolve_materials
67
+ chosen,source,library_tensors,provenance=resolve_materials(config)
68
+ ids=[config.substrate_material,config.electrode_material]+[layer.material_id for layer in config.layers]
69
+ missing=set(int(t) for t in mesh.hex_tag)-set(info['tag_map'])
70
+ if missing:raise ValueError('Unmapped Hex27 material tags: '+str(missing))
71
+ material_type=[dict(rho=chosen[index].rho,C=chosen[index].C,e=chosen[index].e,eps=chosen[index].eps,
72
+ indx=np.where(mesh.hex_tag==tag)[0])
73
+ for tag,index in info['tag_map'].items() if tag!=info['pml_tag']]
74
+ pml_index=info['tag_map'][info['pml_tag']];pml_mat=chosen[pml_index]
75
+ pml=[dict(region=1,indx=np.array([],dtype=np.int64)),dict(region=2,indx=np.array([],dtype=np.int64)),
76
+ dict(region=3,indx=np.where(mesh.hex_tag==info['pml_tag'])[0],za=bottom+info['pml_thickness_m'],zp=bottom,
77
+ xa=0.,xp=0.,dmax=1e11,n=3,rho=pml_mat.rho,C=pml_mat.C,e=pml_mat.e,eps=pml_mat.eps)]
78
+ faces,face_tags=_front_faces(mesh,position)
79
+ tensor_data=dict(material_ids=ids,rho_kg_m3=[m.rho for m in chosen],
80
+ full_C_pa=np.stack([m.C for m in chosen]),full_e_c_m2=np.stack([m.e for m in chosen]),
81
+ full_eps_f_m=np.stack([m.eps for m in chosen]),
82
+ substrate_source_C_pa=source.C,substrate_source_e_c_m2=source.e,substrate_source_eps_f_m=source.eps,
83
+ rotation_matrix=materials.rotation_matrix(angles),euler_angles_deg=angles,
84
+ mode_extension=1,coordinate_mapping='source_xyz_to_mesh_xyz',pml_material_id=ids[pml_index])
85
+ tensor_data.update(library_tensors)
86
+ details=dict(info,material_library=provenance,material_region_ids={str(tag):ids[index] for tag,index in info['tag_map'].items()},
87
+ material_regions={str(tag):ids[index]+(' PML' if tag==info['pml_tag'] else '') for tag,index in info['tag_map'].items()},
88
+ pml_bottom_m=bottom,pml_interface_m=bottom+info['pml_thickness_m'],pml_material_id=ids[pml_index],
89
+ layers=[layer.model_dump() for layer in config.layers],euler_angles_deg=angles,
90
+ substrate_material=config.substrate_material,electrode_material=config.electrode_material,
91
+ mesh_view_axes=[0,2],mesh_view_note='真实前截面 y=0,横轴x、纵轴z;不是三维体渲染。',
92
+ source_compatibility_notes=['Original Hex27 strain matrix omits du_z/dy in gamma_yz; retained for source regression.',
93
+ 'Original PML Voigt stretch and two-stage periodic condensation retained; not independently validated as general 3D.'])
94
+ return HexModel(mesh,position,material_type,pml,boundary_idx_vect_3ds(sets,position,3*mesh.nb_nod),tensor_data,details,faces,face_tags)
sawsim/hex_solver.py ADDED
@@ -0,0 +1,125 @@
1
+ """Bounded Hex27 thin-slice pipeline; original source-compatible operators."""
2
+ import time
3
+ import sys
4
+ import hashlib
5
+ import json
6
+ from pathlib import Path
7
+ import numpy as np
8
+ import matplotlib
9
+ matplotlib.use('Agg')
10
+ import matplotlib.pyplot as plt
11
+ from matplotlib.collections import PolyCollection
12
+ from sawsim.config import ProgressEvent,ResultManifest
13
+ from sawsim.hex_models import build_hex_model
14
+ from sawsim.saw2d.saw3d import form_stiffness_mass_3d,build_periodic_operators,FrequencySolver3D,KM_SCALE
15
+ from sawsim.saw2d.saw3d.bc_solve import _HAVE_PARDISO
16
+ from sawsim.frequency_tasks import charge_stream, worker_count
17
+ ROOT=Path(__file__).resolve().parent
18
+ MAX_HEX_ELEMENTS=2000
19
+ MAX_HEX_NODES=30000
20
+
21
+
22
+ def _front_plot(built,path,title,values=None):
23
+ polygons=built.position_m[built.preview_quads[:,:4]][:,:,[0,2]]*1e6
24
+ fig,ax=plt.subplots(figsize=(5,10))
25
+ if values is None:
26
+ palette=['#4C72B0','#C44E52','#55A868','#8172B2','#CCB974','#64B5CD']
27
+ groups={}
28
+ for tag,label in built.details['material_regions'].items():
29
+ groups.setdefault(label,[]).append(int(tag))
30
+ for index,(label,tags) in enumerate(groups.items()):
31
+ group=np.isin(built.preview_tags,tags)
32
+ if not group.any():continue
33
+ collection=PolyCollection(polygons[group],facecolors=palette[index%len(palette)],edgecolors='#172b3b',linewidths=.45,label=label)
34
+ ax.add_collection(collection)
35
+ ax.legend(loc='upper center',bbox_to_anchor=(.5,-.08),fontsize=7)
36
+ else:
37
+ collection=PolyCollection(polygons,array=np.asarray(values)[built.preview_quads[:,:4]].mean(axis=1),cmap='turbo',edgecolors='face')
38
+ ax.add_collection(collection);fig.colorbar(collection,ax=ax,shrink=.7)
39
+ ax.autoscale_view();ax.set_aspect('equal');ax.set_xlabel('x / um');ax.set_ylabel('z / um')
40
+ ax.set_title(title+'\nFront section y=0 (not volume rendering)',fontsize=9)
41
+ fig.tight_layout();fig.savefig(path,dpi=130,bbox_inches='tight');plt.close(fig)
42
+
43
+
44
+ def run_hex_simulation(cfg,output_dir,on_progress=None,should_cancel=None,*,mesh_only=False):
45
+ from sawsim.solver import atomic_json,dependency_version,publish_mesh
46
+ out=Path(output_dir);out.mkdir(parents=True,exist_ok=True)
47
+ if any((out/name).exists() for name in ['manifest.json','mesh.npz','admittance.npz']):
48
+ raise FileExistsError('输出目录已有计算结果,请使用新的输出目录')
49
+ atomic_json(out/'input.json',cfg.model_dump());started=time.time()
50
+ def check():
51
+ if (out/'cancel').exists() or (should_cancel is not None and should_cancel()):raise InterruptedError('任务已取消')
52
+ def progress(stage,done=0,stage_id='mesh',frequency_hz=None):
53
+ check()
54
+ event=ProgressEvent(stage=stage,stage_id=stage_id,completed=done,total=1 if mesh_only else cfg.points,
55
+ elapsed_seconds=round(time.time()-started,2),frequency_hz=frequency_hz).model_dump()
56
+ atomic_json(out/'progress.json',event)
57
+ if on_progress is not None:on_progress(event)
58
+ check()
59
+ progress('生成 2.5D Hex27 薄片网格')
60
+ built=build_hex_model(cfg);check();mesh=built.mesh;n=mesh.nb_nod;ne=len(mesh.hexas27)
61
+ publish_mesh(out,built.details,position_m=built.position_m,hexas27=mesh.hexas27,material_tags=mesh.hex_tag,
62
+ quads=built.preview_quads,preview_material_tags=built.preview_tags,mesh_view_axes=[0,2])
63
+ np.savez_compressed(out/'material_data.npz',**built.material_data)
64
+ _front_plot(built,out/'mesh.png',cfg.model_id+' mesh')
65
+ from sawsim.material_runtime import snapshot_manifest
66
+ atomic_json(out/'material_snapshot.json',snapshot_manifest(cfg))
67
+ aperture=built.details['aperture_m']
68
+ meta=dict(model_id=cfg.model_id,model='2.5D periodic thin slice / Hex27',mesh_dimension=3,displacement_components=3,
69
+ component_labels=['ux','uy','uz'],mode_extension=1,kinematics='2.5D periodic thin slice',
70
+ aperture_m=aperture,aperture_um=aperture*1e6,nodes=int(n),elements=int(ne),dofs=int(4*n),
71
+ geometry=built.details,mesh_view_axes=[0,2],mesh_view_note=built.details['mesh_view_note'],
72
+ substrate_material=cfg.substrate_material,electrode_material=cfg.electrode_material,
73
+ euler_angles_deg=[cfg.euler_phi_deg,cfg.euler_theta_deg,cfg.euler_psi_deg],euler_convention=cfg.euler_convention,euler_angle_labels=['alpha','beta','gamma'],euler_rotation='Rz(alpha) Rx(beta) Rz(gamma)',
74
+ backend='PARDISO' if _HAVE_PARDISO else 'SciPy SuperLU',solver_version='0.7.0',
75
+ units={'frequency':'Hz','position':'m','displacement':'m','potential':'V','charge':'C/m reaction',
76
+ 'admittance':'S/m','raw_charge_c':'C reaction','raw_admittance_s':'S','aperture_m':'m'},
77
+ normalization='charge=raw_charge_c/aperture_m; admittance=raw_admittance_s/aperture_m; curve uses S/m',
78
+ sign_convention='Source-compatible potential-equation reaction Q; Y=+i*omega*Q/V. For exp(+iwt), this is outward-current admittance, opposite to standard entering-port Y. Magnitude unchanged.',
79
+ source_compatibility_notes=built.details['source_compatibility_notes'],
80
+ resource_limits={'max_hex_elements':MAX_HEX_ELEMENTS,'max_hex_nodes':MAX_HEX_NODES,'numerical_threads':1},
81
+ python=sys.version,versions={name:dependency_version(name) for name in ['numpy','scipy','gmsh','matplotlib']})
82
+ files=[ROOT/'frequency_tasks.py',ROOT/'hex_solver.py',ROOT/'hex_models.py',ROOT/'solver.py',ROOT/'models.py',ROOT/'config.py',ROOT/'material_runtime.py',*sorted((ROOT/'material_library').glob('*.py')),ROOT/'sp_specs.py',ROOT/'saw2d/materials.py',
83
+ *sorted((ROOT/'sp_hex_meshes').glob('*.py')),*sorted((ROOT/'saw2d/saw3d').glob('*.py'))]
84
+ meta['source_hashes']={str(path.relative_to(ROOT)):hashlib.sha256(path.read_bytes()).hexdigest() for path in files}
85
+ if not mesh_only:
86
+ if ne>MAX_HEX_ELEMENTS or n>MAX_HEX_NODES:
87
+ raise ValueError('Hex27 模型超过本机安全组装上限(2000单元/30000节点),请增大 mesh_um 或减小模型尺寸;已保留真实网格。')
88
+ progress('组装 Hex27 矩阵',stage_id='assembly')
89
+ frequencies=np.linspace(cfg.start_ghz*1e9,cfg.stop_ghz*1e9,cfg.points)
90
+ mass,stiffness=form_stiffness_mass_3d(mesh.hexas27,built.position_m,built.material_type,built.pml,omega=2*np.pi*frequencies[-1])
91
+ check();tfb,tlr=build_periodic_operators(built.boundary,4*n)
92
+ engine=FrequencySolver3D(stiffness,mass,built.boundary,tfb,tlr);meta['active_dofs']=int(engine.n_act);meta.update(frequency_count=cfg.points,frequency_workers=worker_count(cfg.points),pml_reference_frequency_hz=float(frequencies[-1]))
93
+ raw_q=[]
94
+ with charge_stream(engine,frequencies,cfg.voltage,is_hex=True) as results:
95
+ for i,f in enumerate(frequencies):
96
+ progress('真实 Hex27 扫频',i,'sweep',float(f))
97
+ q=next(results)
98
+ if not np.isfinite(q):raise ValueError('Hex27 求解产生非有限电荷')
99
+ raw_q.append(complex(q));y=1j*2*np.pi*frequencies[:i+1]*np.array(raw_q)/(cfg.voltage*aperture)
100
+ atomic_json(out/'curve.json',dict(frequency_ghz=(frequencies[:i+1]*1e-9).tolist(),real=y.real.tolist(),imag=y.imag.tolist(),magnitude=np.abs(y).tolist()))
101
+ progress('真实 Hex27 扫频',i+1,'sweep',float(f))
102
+ progress('生成真实前截面场图',cfg.points,'postprocess')
103
+ raw_q=np.array(raw_q);raw_y=1j*2*np.pi*frequencies*raw_q/cfg.voltage;y=raw_y/aperture
104
+ np.savez_compressed(out/'admittance.npz',frequency_hz=frequencies,charge=raw_q/aperture,admittance=y,
105
+ raw_charge_c=raw_q,raw_admittance_s=raw_y,aperture_m=aperture)
106
+ np.savetxt(out/'admittance.csv',np.column_stack([frequencies,y.real,y.imag,np.abs(y),raw_y.real,raw_y.imag,raw_q.real,raw_q.imag]),delimiter=',',
107
+ header='frequency_hz,real_y_s_per_m,imag_y_s_per_m,abs_y_s_per_m,raw_real_y_s,raw_imag_y_s,raw_real_reaction_q_c,raw_imag_reaction_q_c',comments='')
108
+ peak=int(np.argmax(np.abs(y)));state,_,_=engine.solve(2*np.pi*frequencies[peak],voltage=cfg.voltage)
109
+ if not np.isfinite(state).all():raise ValueError('Hex27 场结果包含非有限值')
110
+ displacement=state[:3*n].reshape(n,3)*KM_SCALE;potential=state[3*n:]
111
+ np.savez_compressed(out/'fields.npz',position_m=built.position_m,displacement_m=displacement,potential_v=potential,
112
+ frequency_hz=frequencies[peak],hexas27=mesh.hexas27,quads=built.preview_quads,
113
+ mesh_dimension=3,mode_extension=1,displacement_components=3,component_labels=['ux','uy','uz'],mesh_view_axes=[0,2])
114
+ _front_plot(built,out/'disp_field.png','Displacement magnitude / m',np.linalg.norm(displacement,axis=1));check()
115
+ _front_plot(built,out/'phi_field.png','Potential real part / V',potential.real)
116
+ fig,ax=plt.subplots(figsize=(7,4));ax.plot(frequencies*1e-9,np.abs(y));ax.set_xlabel('Frequency / GHz');ax.set_ylabel('|Y| / (S/m)')
117
+ ax.set_title('2.5D periodic slice; normalized by aperture');ax.grid(alpha=.3);fig.tight_layout();fig.savefig(out/'Y11.png',dpi=150);plt.close(fig)
118
+ meta.update(peak_frequency_ghz=float(frequencies[peak]*1e-9),peak_is_boundary=peak in [0,len(y)-1],field_selection='maximum sampled admittance magnitude')
119
+ else:meta['task_kind']='mesh'
120
+ check();meta['elapsed_seconds']=time.time()-started;atomic_json(out/'metadata.json',meta)
121
+ names=['input.json','material_snapshot.json','metadata.json','mesh.npz','material_data.npz','mesh.png','manifest.json']
122
+ if not mesh_only:names += ['curve.json','admittance.npz','admittance.csv','fields.npz','disp_field.png','phi_field.png','Y11.png']
123
+ artifacts=[dict(name=name,kind='image' if name.endswith('.png') else 'data',media_type={'.png':'image/png','.json':'application/json','.csv':'text/csv','.npz':'application/octet-stream'}[Path(name).suffix]) for name in names]
124
+ manifest=ResultManifest(model_id=cfg.model_id,artifacts=artifacts).model_dump();atomic_json(out/'manifest.json',manifest)
125
+ progress('完成',1 if mesh_only else cfg.points,'complete');return manifest
@@ -0,0 +1,49 @@
1
+ """Dynamic material catalog, with legacy constants retained for compatibility."""
2
+ from copy import deepcopy
3
+
4
+ SUBSTRATES = [
5
+ {'id':'sp_baseline','name':'压电基板 · 基准数据集 I',
6
+ 'description':'复用原单层 SP 完整张量,密度 7450 kg/m³。保留历史数值;原代码 LN 标签不能作为已核实的材料身份。'},
7
+ {'id':'linbo3_tc','name':'LiNbO₃ · TC-SAW 数据集',
8
+ 'description':'复用原 TC-SAW 完整 6×6/3×6/3×3 张量,密度 4628 kg/m³。源数据已在其参考器件坐标系;输入欧拉角是相对该参考系的附加旋转,不代表已核实晶圆切型。'},
9
+ ]
10
+ ELECTRODES = [
11
+ {'id':'al_hex35','name':'Al 铝(ν = 0.35)','description':'各向同性:E = 70 GPa,ν = 0.35,ρ = 2700 kg/m³;e = 0,ε = ε₀I。用于 2.5D 四层模板,与 ν = 0.33 数据集并列保留。'},
12
+ {'id':'al','name':'Al 铝(ν = 0.33)','description':'各向同性:E = 70 GPa,ν = 0.33,ρ = 2700 kg/m³;e = 0,ε = ε₀I。'},
13
+ {'id':'cu','name':'Cu 铜','description':'各向同性:E = 120 GPa,ν = 0.34,ρ = 8960 kg/m³;e = 0,ε = ε₀I。'},
14
+ ]
15
+ LAYER_MATERIALS = [
16
+ {'id':'si_cubic_hex','name':'Si 硅 · 立方各向异性','description':'立方晶系:C11 = 166、C12 = 64、C44 = 80 GPa;ρ = 2330 kg/m³,εr = 11.68。'},
17
+ {'id':'polysi_160','name':'Poly-Si 多晶硅(E = 160 GPa)','description':'各向同性:E = 160 GPa,ν = 0.22,ρ = 2320 kg/m³,εr = 4.5。用于 2.5D 四层模板。'},
18
+ {'id':'si_aniso_hex','name':'Si 硅 · 各向异性全张量','description':'完整 6×6 刚度张量;ρ = 2330 kg/m³,εr = 11.8。用于 2.5D 四层模板。'},
19
+ {'id':'si_isotropic','name':'Si 硅 · 各向同性近似','description':'各向同性近似:E = 170 GPa,ν = 0.28,ρ = 2329 kg/m³,εr = 11.7。'},
20
+ {'id':'sio2','name':'SiO₂ 二氧化硅','description':'各向同性:E = 70 GPa,ν = 0.17,ρ = 2200 kg/m³,εr = 4.2。'},
21
+ {'id':'polysi','name':'Poly-Si 多晶硅(E = 169 GPa)','description':'各向同性:E = 169 GPa,ν = 0.22,ρ = 2320 kg/m³,εr = 4.5。'},
22
+ {'id':'sin','name':'Si₃N₄ 氮化硅','description':'各向同性:E = 160 GPa,ν = 0.23,ρ = 3100 kg/m³,εr = 9.7。'},
23
+ ]
24
+
25
+ EULER_CONVENTION = {
26
+ 'id':'zxz',
27
+ 'description':'内禀 ZXZ 欧拉角:α、β、γ(API 字段 phi、theta、psi)将所选材料数据的源参考系映射至器件全局坐标系。角度单位为度;旋转作用于压电衬底及使用该材料的 PML。各向同性电极不受角度影响。TC-SAW 源数据已经过参考系旋转;统一角度约定不会自动对齐不同源晶轴、模型坐标或命名晶圆切型。',
28
+ 'formula':'A = Rz(α) Rx(β) Rz(γ); C′ = M C Mᵀ, e′ = A e Mᵀ, ε′ = A ε Aᵀ; Rz(t)=[[cos(t),-sin(t),0],[sin(t),cos(t),0],[0,0,1]], Rx(t)=[[1,0,0],[0,cos(t),-sin(t)],[0,sin(t),cos(t)]]; M is the Voigt stress transformation [xx,yy,zz,yz,xz,xy].',
29
+ 'units':'deg',
30
+ 'legacy_conversion':'(α, β, γ) = (-ψ_old, -θ_old, -φ_old)',
31
+ 'reference':'内禀 ZXZ 欧拉角(z-x′-z″),与主流有限元软件的旋转坐标系定义一致;矩阵形式见 formula 字段。',
32
+ }
33
+
34
+
35
+ def get_material_catalog():
36
+ from sawsim.material_library import list_records, record_hash
37
+ records = list_records()
38
+ # list_records sorts numeric-aware versions ascending; expose one current
39
+ # choice per id. Explicit historical versions remain available in registry.
40
+ latest = {record['id']: record for record in records}
41
+ catalog = {'substrates': [], 'electrodes': [], 'layers': [],
42
+ 'euler_convention': deepcopy(EULER_CONVENTION)}
43
+ groups = {'substrate': 'substrates', 'electrode': 'electrodes', 'layer': 'layers'}
44
+ for record in latest.values():
45
+ entry = dict(record, hash=record_hash(record),
46
+ versions=[r['version'] for r in records if r['id'] == record['id']])
47
+ for role in record['roles']:
48
+ catalog[groups[role]].append(deepcopy(entry))
49
+ return catalog