capytaine 2.2.1__cp313-cp313-win_amd64.whl → 2.3.1__cp313-cp313-win_amd64.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 (48) hide show
  1. capytaine/__about__.py +1 -1
  2. capytaine/__init__.py +5 -4
  3. capytaine/bem/airy_waves.py +7 -2
  4. capytaine/bem/problems_and_results.py +91 -39
  5. capytaine/bem/solver.py +128 -40
  6. capytaine/bodies/bodies.py +46 -18
  7. capytaine/bodies/predefined/rectangles.py +2 -0
  8. capytaine/green_functions/FinGreen3D/.gitignore +1 -0
  9. capytaine/green_functions/FinGreen3D/FinGreen3D.f90 +3589 -0
  10. capytaine/green_functions/FinGreen3D/LICENSE +165 -0
  11. capytaine/green_functions/FinGreen3D/Makefile +16 -0
  12. capytaine/green_functions/FinGreen3D/README.md +24 -0
  13. capytaine/green_functions/FinGreen3D/test_program.f90 +39 -0
  14. capytaine/green_functions/LiangWuNoblesse/.gitignore +1 -0
  15. capytaine/green_functions/LiangWuNoblesse/LICENSE +504 -0
  16. capytaine/green_functions/LiangWuNoblesse/LiangWuNoblesseWaveTerm.f90 +751 -0
  17. capytaine/green_functions/LiangWuNoblesse/Makefile +16 -0
  18. capytaine/green_functions/LiangWuNoblesse/README.md +2 -0
  19. capytaine/green_functions/LiangWuNoblesse/test_program.f90 +28 -0
  20. capytaine/green_functions/abstract_green_function.py +55 -3
  21. capytaine/green_functions/delhommeau.py +205 -130
  22. capytaine/green_functions/hams.py +204 -0
  23. capytaine/green_functions/libs/Delhommeau_float32.cp313-win_amd64.dll.a +0 -0
  24. capytaine/green_functions/libs/Delhommeau_float32.cp313-win_amd64.pyd +0 -0
  25. capytaine/green_functions/libs/Delhommeau_float64.cp313-win_amd64.dll.a +0 -0
  26. capytaine/green_functions/libs/Delhommeau_float64.cp313-win_amd64.pyd +0 -0
  27. capytaine/io/bemio.py +14 -2
  28. capytaine/io/mesh_loaders.py +1 -1
  29. capytaine/io/wamit.py +479 -0
  30. capytaine/io/xarray.py +261 -117
  31. capytaine/matrices/linear_solvers.py +1 -1
  32. capytaine/meshes/clipper.py +1 -0
  33. capytaine/meshes/collections.py +19 -1
  34. capytaine/meshes/mesh_like_protocol.py +37 -0
  35. capytaine/meshes/meshes.py +28 -8
  36. capytaine/meshes/symmetric.py +89 -10
  37. capytaine/post_pro/kochin.py +4 -4
  38. capytaine/tools/lists_of_points.py +3 -3
  39. capytaine/tools/prony_decomposition.py +60 -4
  40. capytaine/tools/symbolic_multiplication.py +30 -4
  41. capytaine/tools/timer.py +66 -0
  42. capytaine-2.3.1.dist-info/DELVEWHEEL +2 -0
  43. {capytaine-2.2.1.dist-info → capytaine-2.3.1.dist-info}/METADATA +6 -10
  44. {capytaine-2.2.1.dist-info → capytaine-2.3.1.dist-info}/RECORD +47 -31
  45. capytaine-2.2.1.dist-info/DELVEWHEEL +0 -2
  46. {capytaine-2.2.1.dist-info → capytaine-2.3.1.dist-info}/LICENSE +0 -0
  47. {capytaine-2.2.1.dist-info → capytaine-2.3.1.dist-info}/WHEEL +0 -0
  48. {capytaine-2.2.1.dist-info → capytaine-2.3.1.dist-info}/entry_points.txt +0 -0
capytaine/bem/solver.py CHANGED
@@ -9,21 +9,24 @@
9
9
 
10
10
  """
11
11
 
12
+ import os
12
13
  import logging
13
14
 
14
15
  import numpy as np
16
+ import pandas as pd
15
17
 
16
18
  from datetime import datetime
17
19
 
18
20
  from rich.progress import track
19
21
 
20
- from capytaine.bem.problems_and_results import LinearPotentialFlowProblem
22
+ from capytaine.bem.problems_and_results import LinearPotentialFlowProblem, DiffractionProblem
21
23
  from capytaine.green_functions.delhommeau import Delhommeau
22
24
  from capytaine.bem.engines import BasicMatrixEngine
23
25
  from capytaine.io.xarray import problems_from_dataset, assemble_dataset, kochin_data_array
24
26
  from capytaine.tools.optional_imports import silently_import_optional_dependency
25
27
  from capytaine.tools.lists_of_points import _normalize_points, _normalize_free_surface_points
26
28
  from capytaine.tools.symbolic_multiplication import supporting_symbolic_multiplication
29
+ from capytaine.tools.timer import Timer
27
30
 
28
31
  LOG = logging.getLogger(__name__)
29
32
 
@@ -39,24 +42,39 @@ class BEMSolver:
39
42
  engine: MatrixEngine, optional
40
43
  Object handling the building of matrices and the resolution of linear systems with these matrices.
41
44
  (default: :class:`~capytaine.bem.engines.BasicMatrixEngine`)
45
+ method: string, optional
46
+ select boundary integral equation used to solve the problems.
47
+ Accepted values: "indirect" (as in e.g. Nemoh), "direct" (as in e.g. WAMIT)
48
+ Default value: "indirect"
42
49
 
43
50
  Attributes
44
51
  ----------
52
+ timer: dict[str, Timer]
53
+ Storing the time spent on each subtasks of the resolution
45
54
  exportable_settings : dict
46
55
  Settings of the solver that can be saved to reinit the same solver later.
47
56
  """
48
57
 
49
- def __init__(self, *, green_function=None, engine=None):
58
+ def __init__(self, *, green_function=None, engine=None, method="indirect"):
50
59
  self.green_function = Delhommeau() if green_function is None else green_function
51
60
  self.engine = BasicMatrixEngine() if engine is None else engine
52
61
 
62
+ if method.lower() not in {"direct", "indirect"}:
63
+ raise ValueError(f"Unrecognized method when initializing solver: {repr(method)}. Expected \"direct\" or \"indirect\".")
64
+ self.method = method.lower()
65
+
66
+ self.timer = {"Solve total": Timer(), " Green function": Timer(), " Linear solver": Timer()}
67
+
68
+ self.solve = self.timer["Solve total"].wraps_function(self.solve)
69
+
53
70
  try:
54
71
  self.exportable_settings = {
55
72
  **self.green_function.exportable_settings,
56
- **self.engine.exportable_settings
73
+ **self.engine.exportable_settings,
74
+ "method": self.method,
57
75
  }
58
76
  except AttributeError:
59
- pass
77
+ self.exportable_settings = {}
60
78
 
61
79
  def __str__(self):
62
80
  return f"BEMSolver(engine={self.engine}, green_function={self.green_function})"
@@ -64,6 +82,15 @@ class BEMSolver:
64
82
  def __repr__(self):
65
83
  return self.__str__()
66
84
 
85
+ def timer_summary(self):
86
+ return pd.DataFrame([
87
+ {
88
+ "task": name,
89
+ "total": self.timer[name].total,
90
+ "nb_calls": self.timer[name].nb_timings,
91
+ "mean": self.timer[name].mean
92
+ } for name in self.timer]).set_index("task")
93
+
67
94
  def _repr_pretty_(self, p, cycle):
68
95
  p.text(self.__str__())
69
96
 
@@ -71,7 +98,7 @@ class BEMSolver:
71
98
  def from_exported_settings(settings):
72
99
  raise NotImplementedError
73
100
 
74
- def solve(self, problem, method='indirect', keep_details=True, _check_wavelength=True):
101
+ def solve(self, problem, method=None, keep_details=True, _check_wavelength=True):
75
102
  """Solve the linear potential flow problem.
76
103
 
77
104
  Parameters
@@ -79,7 +106,9 @@ class BEMSolver:
79
106
  problem: LinearPotentialFlowProblem
80
107
  the problem to be solved
81
108
  method: string, optional
82
- select boundary integral approach indirect (i.e.Nemoh)/direct (i.e.WAMIT) (default: indirect)
109
+ select boundary integral equation used to solve the problem.
110
+ It is recommended to set the method more globally when initializing the solver.
111
+ If provided here, the value in argument of `solve` overrides the global one.
83
112
  keep_details: bool, optional
84
113
  if True, store the sources and the potential on the floating body in the output object
85
114
  (default: True)
@@ -98,33 +127,54 @@ class BEMSolver:
98
127
  self._check_wavelength_and_mesh_resolution([problem])
99
128
  self._check_wavelength_and_irregular_frequencies([problem])
100
129
 
130
+ if isinstance(problem, DiffractionProblem) and float(problem.encounter_omega) in {0.0, np.inf}:
131
+ raise ValueError("Diffraction problems at zero or infinite frequency are not defined")
132
+ # This error used to be raised when initializing the problem.
133
+ # It is now raised here, in order to be catchable by
134
+ # _solve_and_catch_errors, such that batch resolution
135
+ # can include this kind of problems without the full batch
136
+ # failing.
137
+ # Note that if this error was not raised here, the resolution
138
+ # would still fail with a less explicit error message.
139
+
101
140
  if problem.forward_speed != 0.0:
102
141
  omega, wavenumber = problem.encounter_omega, problem.encounter_wavenumber
103
142
  else:
104
143
  omega, wavenumber = problem.omega, problem.wavenumber
105
144
 
106
145
  linear_solver = supporting_symbolic_multiplication(self.engine.linear_solver)
146
+ method = method if method is not None else self.method
107
147
  if (method == 'direct'):
108
148
  if problem.forward_speed != 0.0:
109
149
  raise NotImplementedError("Direct solver is not able to solve problems with forward speed.")
110
150
 
111
- S, D = self.engine.build_matrices(
112
- problem.body.mesh_including_lid, problem.body.mesh_including_lid,
113
- problem.free_surface, problem.water_depth, wavenumber,
114
- self.green_function, adjoint_double_layer=False
115
- )
116
-
117
- potential = linear_solver(D, S @ problem.boundary_condition)
151
+ with self.timer[" Green function"]:
152
+ S, D = self.engine.build_matrices(
153
+ problem.body.mesh_including_lid, problem.body.mesh_including_lid,
154
+ problem.free_surface, problem.water_depth, wavenumber,
155
+ self.green_function, adjoint_double_layer=False
156
+ )
157
+ rhs = S @ problem.boundary_condition
158
+ with self.timer[" Linear solver"]:
159
+ potential = linear_solver(D, rhs)
160
+ if not potential.shape == problem.boundary_condition.shape:
161
+ raise ValueError(f"Error in linear solver of {self.engine}: the shape of the output ({potential.shape}) "
162
+ f"does not match the expected shape ({problem.boundary_condition.shape})")
118
163
  pressure = 1j * omega * problem.rho * potential
119
164
  sources = None
120
165
  else:
121
- S, K = self.engine.build_matrices(
122
- problem.body.mesh_including_lid, problem.body.mesh_including_lid,
123
- problem.free_surface, problem.water_depth, wavenumber,
124
- self.green_function, adjoint_double_layer=True
125
- )
166
+ with self.timer[" Green function"]:
167
+ S, K = self.engine.build_matrices(
168
+ problem.body.mesh_including_lid, problem.body.mesh_including_lid,
169
+ problem.free_surface, problem.water_depth, wavenumber,
170
+ self.green_function, adjoint_double_layer=True
171
+ )
126
172
 
127
- sources = linear_solver(K, problem.boundary_condition)
173
+ with self.timer[" Linear solver"]:
174
+ sources = linear_solver(K, problem.boundary_condition)
175
+ if not sources.shape == problem.boundary_condition.shape:
176
+ raise ValueError(f"Error in linear solver of {self.engine}: the shape of the output ({sources.shape}) "
177
+ f"does not match the expected shape ({problem.boundary_condition.shape})")
128
178
  potential = S @ sources
129
179
  pressure = 1j * omega * problem.rho * potential
130
180
  if problem.forward_speed != 0.0:
@@ -133,7 +183,7 @@ class BEMSolver:
133
183
  nabla_phi = self._compute_potential_gradient(problem.body.mesh_including_lid, result)
134
184
  pressure += problem.rho * problem.forward_speed * nabla_phi[:, 0]
135
185
 
136
- pressure_on_hull = pressure[:problem.body.mesh.nb_faces] # Discards pressure on lid if any
186
+ pressure_on_hull = pressure[problem.body.hull_mask] # Discards pressure on lid if any
137
187
  forces = problem.body.integrate_pressure(pressure_on_hull)
138
188
 
139
189
  if not keep_details:
@@ -145,7 +195,17 @@ class BEMSolver:
145
195
 
146
196
  return result
147
197
 
148
- def solve_all(self, problems, *, method='indirect', n_jobs=1, progress_bar=True, _check_wavelength=True, **kwargs):
198
+ def _solve_and_catch_errors(self, problem, *args, **kwargs):
199
+ """Same as BEMSolver.solve() but returns a
200
+ FailedLinearPotentialFlowResult when the resolution failed."""
201
+ try:
202
+ res = self.solve(problem, *args, **kwargs)
203
+ except Exception as e:
204
+ LOG.info(f"Skipped {problem}\nbecause of {repr(e)}")
205
+ res = problem.make_failed_results_container(e)
206
+ return res
207
+
208
+ def solve_all(self, problems, *, method=None, n_jobs=1, progress_bar=None, _check_wavelength=True, **kwargs):
149
209
  """Solve several problems.
150
210
  Optional keyword arguments are passed to `BEMSolver.solve`.
151
211
 
@@ -154,12 +214,17 @@ class BEMSolver:
154
214
  problems: list of LinearPotentialFlowProblem
155
215
  several problems to be solved
156
216
  method: string, optional
157
- select boundary integral approach indirect (i.e.Nemoh)/direct (i.e.WAMIT) (default: indirect)
217
+ select boundary integral equation used to solve the problems.
218
+ It is recommended to set the method more globally when initializing the solver.
219
+ If provided here, the value in argument of `solve_all` overrides the global one.
158
220
  n_jobs: int, optional (default: 1)
159
221
  the number of jobs to run in parallel using the optional dependency `joblib`
160
222
  By defaults: do not use joblib and solve sequentially.
161
- progress_bar: bool, optional (default: True)
162
- Display a progress bar while solving
223
+ progress_bar: bool, optional
224
+ Display a progress bar while solving.
225
+ If no value is provided to this method directly,
226
+ check whether the environment variable `CAPYTAINE_PROGRESS_BAR` is defined
227
+ and otherwise default to True.
163
228
  _check_wavelength: bool, optional (default: True)
164
229
  If True, the frequencies are compared to the mesh resolution and
165
230
  the estimated first irregular frequency to warn the user.
@@ -173,11 +238,23 @@ class BEMSolver:
173
238
  self._check_wavelength_and_mesh_resolution(problems)
174
239
  self._check_wavelength_and_irregular_frequencies(problems)
175
240
 
241
+ if progress_bar is None:
242
+ if "CAPYTAINE_PROGRESS_BAR" in os.environ:
243
+ env_var = os.environ["CAPYTAINE_PROGRESS_BAR"].lower()
244
+ if env_var in {'true', '1', 't'}:
245
+ progress_bar = True
246
+ elif env_var in {'false', '0', 'f'}:
247
+ progress_bar = False
248
+ else:
249
+ raise ValueError("Invalid value '{}' for the environment variable CAPYTAINE_PROGRESS_BAR.".format(os.environ["CAPYTAINE_PROGRESS_BAR"]))
250
+ else:
251
+ progress_bar = True
252
+
176
253
  if n_jobs == 1: # force sequential resolution
177
254
  problems = sorted(problems)
178
255
  if progress_bar:
179
256
  problems = track(problems, total=len(problems), description="Solving BEM problems")
180
- return [self.solve(pb, method=method, _check_wavelength=False, **kwargs) for pb in problems]
257
+ results = [self._solve_and_catch_errors(pb, method=method, _check_wavelength=False, **kwargs) for pb in problems]
181
258
  else:
182
259
  joblib = silently_import_optional_dependency("joblib")
183
260
  if joblib is None:
@@ -190,7 +267,8 @@ class BEMSolver:
190
267
  total=len(groups_of_problems),
191
268
  description=f"Solving BEM problems with {n_jobs} threads:")
192
269
  results = [res for grp in groups_of_results for res in grp] # flatten the nested list
193
- return results
270
+ LOG.info("Solver timer summary:\n%s", self.timer_summary())
271
+ return results
194
272
 
195
273
  @staticmethod
196
274
  def _check_wavelength_and_mesh_resolution(problems):
@@ -225,7 +303,8 @@ class BEMSolver:
225
303
  """Display a warning if some of the problems might encounter irregular frequencies."""
226
304
  LOG.debug("Check wavelength with estimated irregular frequency.")
227
305
  risky_problems = [pb for pb in problems
228
- if pb.body.first_irregular_frequency_estimate(g=pb.g) < pb.omega < np.inf]
306
+ if pb.free_surface != np.inf and
307
+ pb.body.first_irregular_frequency_estimate(g=pb.g) < pb.omega < np.inf]
229
308
  nb_risky_problems = len(risky_problems)
230
309
  if nb_risky_problems >= 1:
231
310
  if any(pb.body.lid_mesh is None for pb in problems):
@@ -250,7 +329,7 @@ class BEMSolver:
250
329
  + recommendation
251
330
  )
252
331
 
253
- def fill_dataset(self, dataset, bodies, *, method='indirect', n_jobs=1, _check_wavelength=True, **kwargs):
332
+ def fill_dataset(self, dataset, bodies, *, method=None, n_jobs=1, _check_wavelength=True, progress_bar=None, **kwargs):
254
333
  """Solve a set of problems defined by the coordinates of an xarray dataset.
255
334
 
256
335
  Parameters
@@ -261,12 +340,17 @@ class BEMSolver:
261
340
  The body or bodies involved in the problems
262
341
  They should all have different names.
263
342
  method: string, optional
264
- select boundary integral approach indirect (i.e.Nemoh)/direct (i.e.WAMIT) (default: indirect)
343
+ select boundary integral equation used to solve the problems.
344
+ It is recommended to set the method more globally when initializing the solver.
345
+ If provided here, the value in argument of `fill_dataset` overrides the global one.
265
346
  n_jobs: int, optional (default: 1)
266
347
  the number of jobs to run in parallel using the optional dependency `joblib`
267
348
  By defaults: do not use joblib and solve sequentially.
268
- progress_bar: bool, optional (default: True)
269
- Display a progress bar while solving
349
+ progress_bar: bool, optional
350
+ Display a progress bar while solving.
351
+ If no value is provided to this method directly,
352
+ check whether the environment variable `CAPYTAINE_PROGRESS_BAR` is defined
353
+ and otherwise default to True.
270
354
  _check_wavelength: bool, optional (default: True)
271
355
  If True, the frequencies are compared to the mesh resolution and
272
356
  the estimated first irregular frequency to warn the user.
@@ -277,14 +361,16 @@ class BEMSolver:
277
361
  """
278
362
  attrs = {'start_of_computation': datetime.now().isoformat(),
279
363
  **self.exportable_settings}
364
+ if method is not None: # Overrides the method in self.exportable_settings
365
+ attrs["method"] = method
280
366
  problems = problems_from_dataset(dataset, bodies)
281
367
  if 'theta' in dataset.coords:
282
- results = self.solve_all(problems, keep_details=True, method=method, n_jobs=n_jobs, _check_wavelength=_check_wavelength)
368
+ results = self.solve_all(problems, keep_details=True, method=method, n_jobs=n_jobs, _check_wavelength=_check_wavelength, progress_bar=progress_bar)
283
369
  kochin = kochin_data_array(results, dataset.coords['theta'])
284
370
  dataset = assemble_dataset(results, attrs=attrs, **kwargs)
285
371
  dataset.update(kochin)
286
372
  else:
287
- results = self.solve_all(problems, keep_details=False, method=method, n_jobs=n_jobs, _check_wavelength=_check_wavelength)
373
+ results = self.solve_all(problems, keep_details=False, method=method, n_jobs=n_jobs, _check_wavelength=_check_wavelength, progress_bar=progress_bar)
288
374
  dataset = assemble_dataset(results, attrs=attrs, **kwargs)
289
375
  return dataset
290
376
 
@@ -294,7 +380,7 @@ class BEMSolver:
294
380
 
295
381
  Parameters
296
382
  ----------
297
- points: array of shape (3,) or (N, 3), or 3-ple of arrays returned by meshgrid, or cpt.Mesh or cpt.CollectionOfMeshes object
383
+ points: array of shape (3,) or (N, 3), or 3-ple of arrays returned by meshgrid, or MeshLike object
298
384
  Coordinates of the point(s) at which the potential should be computed
299
385
  result: LinearPotentialFlowResult
300
386
  The return of the BEM solver
@@ -314,7 +400,8 @@ class BEMSolver:
314
400
  They probably have not been stored by the solver because the option keep_details=True have not been set or the direct method has been used.
315
401
  Please re-run the resolution with the indirect method and keep_details=True.""")
316
402
 
317
- S, _ = self.green_function.evaluate(points, result.body.mesh_including_lid, result.free_surface, result.water_depth, result.encounter_wavenumber)
403
+ with self.timer[" Green function"]:
404
+ S, _ = self.green_function.evaluate(points, result.body.mesh_including_lid, result.free_surface, result.water_depth, result.encounter_wavenumber)
318
405
  potential = S @ result.sources # Sum the contributions of all panels in the mesh
319
406
  return potential.reshape(output_shape)
320
407
 
@@ -326,7 +413,8 @@ class BEMSolver:
326
413
  They probably have not been stored by the solver because the option keep_details=True have not been set.
327
414
  Please re-run the resolution with this option.""")
328
415
 
329
- _, gradG = self.green_function.evaluate(points, result.body.mesh_including_lid, result.free_surface, result.water_depth, result.encounter_wavenumber,
416
+ with self.timer[" Green function"]:
417
+ _, gradG = self.green_function.evaluate(points, result.body.mesh_including_lid, result.free_surface, result.water_depth, result.encounter_wavenumber,
330
418
  early_dot_product=False)
331
419
  velocities = np.einsum('ijk,j->ik', gradG, result.sources) # Sum the contributions of all panels in the mesh
332
420
  return velocities.reshape((*output_shape, 3))
@@ -336,7 +424,7 @@ class BEMSolver:
336
424
 
337
425
  Parameters
338
426
  ----------
339
- points: array of shape (3,) or (N, 3), or 3-ple of arrays returned by meshgrid, or cpt.Mesh or cpt.CollectionOfMeshes object
427
+ points: array of shape (3,) or (N, 3), or 3-ple of arrays returned by meshgrid, or MeshLike object
340
428
  Coordinates of the point(s) at which the velocity should be computed
341
429
  result: LinearPotentialFlowResult
342
430
  The return of the BEM solver
@@ -360,7 +448,7 @@ class BEMSolver:
360
448
 
361
449
  Parameters
362
450
  ----------
363
- points: array of shape (3,) or (N, 3), or 3-ple of arrays returned by meshgrid, or cpt.Mesh or cpt.CollectionOfMeshes object
451
+ points: array of shape (3,) or (N, 3), or 3-ple of arrays returned by meshgrid, or MeshLike object
364
452
  Coordinates of the point(s) at which the pressure should be computed
365
453
  result: LinearPotentialFlowResult
366
454
  The return of the BEM solver
@@ -388,7 +476,7 @@ class BEMSolver:
388
476
 
389
477
  Parameters
390
478
  ----------
391
- points: array of shape (2,) or (N, 2), or 2-ple of arrays returned by meshgrid, or cpt.Mesh or cpt.CollectionOfMeshes object
479
+ points: array of shape (2,) or (N, 2), or 2-ple of arrays returned by meshgrid, or MeshLike object
392
480
  Coordinates of the point(s) at which the free surface elevation should be computed
393
481
  result: LinearPotentialFlowResult
394
482
  The return of the BEM solver
@@ -427,7 +515,7 @@ class BEMSolver:
427
515
  ----------
428
516
  result : LinearPotentialFlowResult
429
517
  the return of the BEM solver
430
- mesh : Mesh or CollectionOfMeshes
518
+ mesh : MeshLike
431
519
  a mesh
432
520
  chunk_size: int, optional
433
521
  Number of lines to compute in the matrix.
@@ -10,6 +10,7 @@ from functools import cached_property, lru_cache
10
10
  import numpy as np
11
11
  import xarray as xr
12
12
 
13
+ from capytaine.meshes.mesh_like_protocol import MeshLike
13
14
  from capytaine.meshes.collections import CollectionOfMeshes
14
15
  from capytaine.meshes.geometry import Abstract3DObject, ClippableMixin, Plane, inplace_transformation
15
16
  from capytaine.meshes.properties import connected_components, connected_components_of_waterline
@@ -39,10 +40,10 @@ class FloatingBody(ClippableMixin, Abstract3DObject):
39
40
 
40
41
  Parameters
41
42
  ----------
42
- mesh : Mesh or CollectionOfMeshes, optional
43
+ mesh : MeshLike, optional
43
44
  the mesh describing the geometry of the hull of the floating body.
44
45
  If none is given, a empty one is created.
45
- lid_mesh : Mesh or CollectionOfMeshes or None, optional
46
+ lid_mesh : MeshLike or None, optional
46
47
  a mesh of an internal lid for irregular frequencies removal.
47
48
  Unlike the mesh of the hull, no dof is defined on the lid_mesh.
48
49
  If none is given, none is used when solving the Boundary Integral Equation.
@@ -69,7 +70,7 @@ class FloatingBody(ClippableMixin, Abstract3DObject):
69
70
  from capytaine.io.meshio import load_from_meshio
70
71
  self.mesh = load_from_meshio(mesh)
71
72
 
72
- elif isinstance(mesh, Mesh) or isinstance(mesh, CollectionOfMeshes):
73
+ elif isinstance(mesh, MeshLike):
73
74
  self.mesh = mesh
74
75
 
75
76
  else:
@@ -87,7 +88,10 @@ class FloatingBody(ClippableMixin, Abstract3DObject):
87
88
  if name is None and mesh is None:
88
89
  self.name = "dummy_body"
89
90
  elif name is None:
90
- self.name = self.mesh.name
91
+ if hasattr(self.mesh, "name"):
92
+ self.name = self.mesh.name
93
+ else:
94
+ self.name = "anonymous_body"
91
95
  else:
92
96
  self.name = name
93
97
 
@@ -97,7 +101,7 @@ class FloatingBody(ClippableMixin, Abstract3DObject):
97
101
  else:
98
102
  self.center_of_mass = None
99
103
 
100
- if self.mesh.nb_vertices > 0 and self.mesh.nb_faces > 0:
104
+ if hasattr(self.mesh, "heal_mesh") and self.mesh.nb_vertices > 0 and self.mesh.nb_faces > 0:
101
105
  self.mesh.heal_mesh()
102
106
 
103
107
  if dofs is None:
@@ -110,8 +114,21 @@ class FloatingBody(ClippableMixin, Abstract3DObject):
110
114
  else:
111
115
  self.dofs = dofs
112
116
 
117
+ self._evaluate_full_mesh()
118
+
113
119
  LOG.info(f"New floating body: {self.__str__()}.")
114
120
 
121
+ self._check_dofs_shape_consistency()
122
+
123
+ def _evaluate_full_mesh(self):
124
+ """Merge the mesh and lid_mesh, while keeping track of where each panel came from."""
125
+ if self.lid_mesh is None:
126
+ self.mesh_including_lid = self.mesh
127
+ self.hull_mask = np.full((self.mesh.nb_faces,), True)
128
+ else:
129
+ self.mesh_including_lid, masks = self.mesh.join_meshes(self.lid_mesh, return_masks=True)
130
+ self.hull_mask = masks[0]
131
+
115
132
  @staticmethod
116
133
  def from_meshio(mesh, name=None) -> 'FloatingBody':
117
134
  """Create a FloatingBody from a meshio mesh object.
@@ -132,13 +149,6 @@ class FloatingBody(ClippableMixin, Abstract3DObject):
132
149
  """Arbitrary order. The point is to sort together the problems involving the same body."""
133
150
  return self.name < other.name
134
151
 
135
- @cached_property
136
- def mesh_including_lid(self):
137
- if self.lid_mesh is not None:
138
- return CollectionOfMeshes([self.mesh, self.lid_mesh])
139
- else:
140
- return self.mesh
141
-
142
152
  ##########
143
153
  # Dofs #
144
154
  ##########
@@ -264,6 +274,14 @@ class FloatingBody(ClippableMixin, Abstract3DObject):
264
274
  coords={'influenced_dof': list(self.dofs), 'radiating_dof': list(self.dofs)},
265
275
  )
266
276
 
277
+ def _check_dofs_shape_consistency(self):
278
+ for dof_name, dof in self.dofs.items():
279
+ if np.array(dof).shape != (self.mesh.nb_faces, 3):
280
+ raise ValueError(f"The array defining the dof {dof_name} of body {self.name} does not have the expected shape.\n"
281
+ f"Expected shape: ({self.mesh.nb_faces}, 3)\n"
282
+ f" Actual shape: {dof.shape}")
283
+
284
+
267
285
  ###################
268
286
  # Hydrostatics #
269
287
  ###################
@@ -556,7 +574,7 @@ class FloatingBody(ClippableMixin, Abstract3DObject):
556
574
  )
557
575
  for radiating_dof_name in self.dofs
558
576
  for influenced_dof_name in self.dofs
559
- ])
577
+ ], compat='no_conflicts', join="outer")
560
578
 
561
579
  # Reorder dofs
562
580
  K = hs_set.hydrostatic_stiffness.sel(influenced_dof=list(self.dofs.keys()), radiating_dof=list(self.dofs.keys()))
@@ -646,7 +664,7 @@ class FloatingBody(ClippableMixin, Abstract3DObject):
646
664
  'radiating_dof': body_dof_names},
647
665
  name="inertia_matrix")
648
666
 
649
- total_mass_xr = xr.merge([rigid_inertia_matrix_xr, other_dofs_inertia_matrix_xr], compat="override").inertia_matrix
667
+ total_mass_xr = xr.merge([rigid_inertia_matrix_xr, other_dofs_inertia_matrix_xr], compat="override", join="outer").inertia_matrix
650
668
 
651
669
  non_rigid_dofs = set(body_dof_names) - set(rigid_dof_names)
652
670
 
@@ -756,14 +774,14 @@ respective inertia coefficients are assigned as NaN.")
756
774
  if name is None:
757
775
  name = "+".join(body.name for body in bodies)
758
776
  meshes = CollectionOfMeshes(
759
- [body.mesh for body in bodies],
777
+ [body.mesh.copy() for body in bodies],
760
778
  name=f"{name}_mesh"
761
779
  )
762
780
  if all(body.lid_mesh is None for body in bodies):
763
781
  lid_meshes = None
764
782
  else:
765
783
  lid_meshes = CollectionOfMeshes(
766
- [body.lid_mesh for body in bodies if body.lid_mesh is not None],
784
+ [body.lid_mesh.copy() for body in bodies if body.lid_mesh is not None],
767
785
  name=f"{name}_lid_mesh"
768
786
  )
769
787
  dofs = FloatingBody.combine_dofs(bodies)
@@ -796,6 +814,8 @@ respective inertia coefficients are assigned as NaN.")
796
814
  @staticmethod
797
815
  def combine_dofs(bodies) -> dict:
798
816
  """Combine the degrees of freedom of several bodies."""
817
+ for body in bodies:
818
+ body._check_dofs_shape_consistency()
799
819
  dofs = {}
800
820
  cum_nb_faces = accumulate(chain([0], (body.mesh.nb_faces for body in bodies)))
801
821
  total_nb_faces = sum(body.mesh.nb_faces for body in bodies)
@@ -823,6 +843,8 @@ respective inertia coefficients are assigned as NaN.")
823
843
  name : str, optional
824
844
  a name for the new copy
825
845
  """
846
+ self._check_dofs_shape_consistency()
847
+
826
848
  new_body = copy.deepcopy(self)
827
849
  if name is None:
828
850
  new_body.name = f"copy_of_{self.name}"
@@ -878,9 +900,9 @@ respective inertia coefficients are assigned as NaN.")
878
900
  raise NotImplementedError # TODO
879
901
 
880
902
  if return_index:
881
- new_mesh, id_v = Mesh.extract_faces(self.mesh, id_faces_to_extract, return_index)
903
+ new_mesh, id_v = self.mesh.extract_faces(id_faces_to_extract, return_index)
882
904
  else:
883
- new_mesh = Mesh.extract_faces(self.mesh, id_faces_to_extract, return_index)
905
+ new_mesh = self.mesh.extract_faces(id_faces_to_extract, return_index)
884
906
  new_body = FloatingBody(new_mesh)
885
907
  LOG.info(f"Extract floating body from {self.name}.")
886
908
 
@@ -962,6 +984,7 @@ respective inertia coefficients are assigned as NaN.")
962
984
  self.mesh.mirror(plane)
963
985
  if self.lid_mesh is not None:
964
986
  self.lid_mesh.mirror(plane)
987
+ self._evaluate_full_mesh()
965
988
  for dof in self.dofs:
966
989
  self.dofs[dof] -= 2 * np.outer(np.dot(self.dofs[dof], plane.normal), plane.normal)
967
990
  for point_attr in ('geometric_center', 'rotation_center', 'center_of_mass'):
@@ -976,6 +999,7 @@ respective inertia coefficients are assigned as NaN.")
976
999
  self.mesh.translate(vector, *args, **kwargs)
977
1000
  if self.lid_mesh is not None:
978
1001
  self.lid_mesh.translate(vector, *args, **kwargs)
1002
+ self._evaluate_full_mesh()
979
1003
  for point_attr in ('geometric_center', 'rotation_center', 'center_of_mass'):
980
1004
  if point_attr in self.__dict__ and self.__dict__[point_attr] is not None:
981
1005
  self.__dict__[point_attr] = np.array(self.__dict__[point_attr]) + vector
@@ -986,6 +1010,7 @@ respective inertia coefficients are assigned as NaN.")
986
1010
  self.mesh.rotate(axis, angle)
987
1011
  if self.lid_mesh is not None:
988
1012
  self.lid_mesh.rotate(axis, angle)
1013
+ self._evaluate_full_mesh()
989
1014
  for point_attr in ('geometric_center', 'rotation_center', 'center_of_mass'):
990
1015
  if point_attr in self.__dict__ and self.__dict__[point_attr] is not None:
991
1016
  self.__dict__[point_attr] = axis.rotate_points([self.__dict__[point_attr]], angle)[0, :]
@@ -995,6 +1020,8 @@ respective inertia coefficients are assigned as NaN.")
995
1020
 
996
1021
  @inplace_transformation
997
1022
  def clip(self, plane):
1023
+ self._check_dofs_shape_consistency()
1024
+
998
1025
  # Clip mesh
999
1026
  LOG.info(f"Clipping {self.name} with respect to {plane}")
1000
1027
  self.mesh.clip(plane)
@@ -1003,6 +1030,7 @@ respective inertia coefficients are assigned as NaN.")
1003
1030
  if self.lid_mesh.nb_faces == 0:
1004
1031
  LOG.warning("Lid mesh %s is empty after clipping. The lid mesh is removed.", self.lid_mesh)
1005
1032
  self.lid_mesh = None
1033
+ self._evaluate_full_mesh()
1006
1034
 
1007
1035
  # Clip dofs
1008
1036
  ids = self.mesh._clipping_data['faces_ids']
@@ -101,6 +101,8 @@ class RectangularParallelepiped(FloatingBody):
101
101
  translation_symmetry=translational_symmetry, reflection_symmetry=reflection_symmetry,
102
102
  name=f"{name}_mesh")
103
103
 
104
+ self.geometric_center = np.asarray(center, dtype=float)
105
+
104
106
  FloatingBody.__init__(self, mesh=mesh, name=name)
105
107
 
106
108
  class OpenRectangularParallelepiped(RectangularParallelepiped):
@@ -0,0 +1 @@
1
+ build/