matterviz 0.4.0 → 0.4.1

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 (326) hide show
  1. package/dist/brillouin/BrillouinZone.svelte +68 -145
  2. package/dist/brillouin/BrillouinZone.svelte.d.ts +5 -14
  3. package/dist/brillouin/BrillouinZoneExportPane.svelte +43 -96
  4. package/dist/brillouin/BrillouinZoneExportPane.svelte.d.ts +1 -1
  5. package/dist/brillouin/BrillouinZoneInfoPane.svelte +9 -32
  6. package/dist/brillouin/BrillouinZoneInfoPane.svelte.d.ts +2 -3
  7. package/dist/brillouin/BrillouinZoneScene.svelte +49 -203
  8. package/dist/brillouin/BrillouinZoneScene.svelte.d.ts +3 -23
  9. package/dist/brillouin/ReciprocalVectors.svelte +39 -0
  10. package/dist/brillouin/ReciprocalVectors.svelte.d.ts +9 -0
  11. package/dist/brillouin/compute.d.ts +2 -0
  12. package/dist/brillouin/compute.js +80 -77
  13. package/dist/brillouin/geometry.d.ts +8 -0
  14. package/dist/brillouin/geometry.js +57 -0
  15. package/dist/brillouin/index.d.ts +2 -0
  16. package/dist/brillouin/index.js +2 -0
  17. package/dist/brillouin/types.d.ts +2 -2
  18. package/dist/chempot-diagram/ChemPotDiagram.svelte.d.ts +1 -1
  19. package/dist/chempot-diagram/ChemPotDiagram2D.svelte +100 -191
  20. package/dist/chempot-diagram/ChemPotDiagram2D.svelte.d.ts +4 -1
  21. package/dist/chempot-diagram/ChemPotDiagram3D.svelte +176 -464
  22. package/dist/chempot-diagram/ChemPotDiagram3D.svelte.d.ts +7 -1
  23. package/dist/chempot-diagram/color.d.ts +3 -6
  24. package/dist/chempot-diagram/color.js +5 -5
  25. package/dist/chempot-diagram/compute.d.ts +3 -3
  26. package/dist/chempot-diagram/compute.js +3 -1
  27. package/dist/chempot-diagram/controls-state.svelte.d.ts +10 -0
  28. package/dist/chempot-diagram/controls-state.svelte.js +42 -0
  29. package/dist/chempot-diagram/export.d.ts +47 -0
  30. package/dist/chempot-diagram/export.js +133 -0
  31. package/dist/chempot-diagram/index.d.ts +1 -0
  32. package/dist/chempot-diagram/index.js +1 -0
  33. package/dist/chempot-diagram/pointer.d.ts +0 -10
  34. package/dist/chempot-diagram/pointer.js +4 -4
  35. package/dist/chempot-diagram/types.d.ts +3 -3
  36. package/dist/colors/index.js +2 -2
  37. package/dist/composition/FormulaFilter.svelte +6 -5
  38. package/dist/composition/PieChart.svelte +5 -5
  39. package/dist/composition/chem-sys.js +3 -2
  40. package/dist/composition/format.js +3 -2
  41. package/dist/composition/parse.d.ts +0 -1
  42. package/dist/composition/parse.js +17 -19
  43. package/dist/controls.d.ts +1 -0
  44. package/dist/controls.js +0 -1
  45. package/dist/convex-hull/ConvexHull.svelte +8 -10
  46. package/dist/convex-hull/ConvexHull.svelte.d.ts +1 -4
  47. package/dist/convex-hull/ConvexHull2D.svelte +94 -175
  48. package/dist/convex-hull/ConvexHull2D.svelte.d.ts +1 -1
  49. package/dist/convex-hull/ConvexHull3D.svelte +176 -680
  50. package/dist/convex-hull/ConvexHull3D.svelte.d.ts +1 -1
  51. package/dist/convex-hull/ConvexHull4D.svelte +180 -680
  52. package/dist/convex-hull/ConvexHull4D.svelte.d.ts +1 -1
  53. package/dist/convex-hull/ConvexHullChrome.svelte +268 -0
  54. package/dist/convex-hull/ConvexHullChrome.svelte.d.ts +30 -0
  55. package/dist/convex-hull/ConvexHullControls.svelte +88 -7
  56. package/dist/convex-hull/ConvexHullControls.svelte.d.ts +7 -6
  57. package/dist/convex-hull/ConvexHullInfoPane.svelte +18 -5
  58. package/dist/convex-hull/ConvexHullInfoPane.svelte.d.ts +6 -5
  59. package/dist/convex-hull/ConvexHullStats.svelte +29 -168
  60. package/dist/convex-hull/ConvexHullStats.svelte.d.ts +3 -1
  61. package/dist/convex-hull/ConvexHullTooltip.svelte +11 -2
  62. package/dist/convex-hull/ConvexHullTooltip.svelte.d.ts +2 -1
  63. package/dist/convex-hull/barycentric-coords.d.ts +2 -4
  64. package/dist/convex-hull/barycentric-coords.js +6 -33
  65. package/dist/convex-hull/canvas-interactions.svelte.d.ts +79 -0
  66. package/dist/convex-hull/canvas-interactions.svelte.js +278 -0
  67. package/dist/convex-hull/helpers.d.ts +39 -7
  68. package/dist/convex-hull/helpers.js +154 -69
  69. package/dist/convex-hull/hull-state.svelte.d.ts +44 -0
  70. package/dist/convex-hull/hull-state.svelte.js +124 -0
  71. package/dist/convex-hull/index.d.ts +9 -7
  72. package/dist/convex-hull/index.js +7 -2
  73. package/dist/convex-hull/thermodynamics.js +91 -920
  74. package/dist/convex-hull/types.d.ts +12 -4
  75. package/dist/convex-hull/types.js +12 -0
  76. package/dist/coordination/CoordinationBarPlot.svelte +4 -11
  77. package/dist/element/BohrAtom.svelte +2 -1
  78. package/dist/element/ElementTile.svelte.d.ts +1 -1
  79. package/dist/element/index.d.ts +4 -0
  80. package/dist/element/index.js +18 -0
  81. package/dist/feedback/DragOverlay.svelte +3 -1
  82. package/dist/feedback/DragOverlay.svelte.d.ts +1 -0
  83. package/dist/feedback/StatusMessage.svelte +13 -3
  84. package/dist/fermi-surface/FermiSurface.svelte +67 -146
  85. package/dist/fermi-surface/FermiSurface.svelte.d.ts +5 -14
  86. package/dist/fermi-surface/FermiSurfaceControls.svelte.d.ts +1 -1
  87. package/dist/fermi-surface/FermiSurfaceScene.svelte +72 -224
  88. package/dist/fermi-surface/FermiSurfaceScene.svelte.d.ts +3 -23
  89. package/dist/fermi-surface/compute.js +11 -10
  90. package/dist/fermi-surface/export.js +4 -15
  91. package/dist/fermi-surface/index.d.ts +0 -1
  92. package/dist/fermi-surface/index.js +0 -1
  93. package/dist/fermi-surface/parse.d.ts +1 -1
  94. package/dist/fermi-surface/parse.js +64 -75
  95. package/dist/fermi-surface/types.d.ts +2 -2
  96. package/dist/heatmap-matrix/HeatmapMatrix.svelte +55 -40
  97. package/dist/heatmap-matrix/HeatmapMatrix.svelte.d.ts +4 -3
  98. package/dist/heatmap-matrix/HeatmapMatrixControls.svelte +3 -2
  99. package/dist/heatmap-matrix/HeatmapMatrixControls.svelte.d.ts +5 -5
  100. package/dist/heatmap-matrix/index.d.ts +3 -2
  101. package/dist/index.d.ts +1 -0
  102. package/dist/index.js +1 -0
  103. package/dist/io/ExportPane.svelte +166 -0
  104. package/dist/io/ExportPane.svelte.d.ts +17 -0
  105. package/dist/io/decompress.js +1 -2
  106. package/dist/io/export.d.ts +5 -1
  107. package/dist/io/export.js +32 -28
  108. package/dist/io/fetch.d.ts +2 -1
  109. package/dist/io/file-drop.d.ts +7 -0
  110. package/dist/io/file-drop.js +13 -0
  111. package/dist/io/index.d.ts +2 -0
  112. package/dist/io/index.js +10 -0
  113. package/dist/io/types.d.ts +13 -0
  114. package/dist/isosurface/parse.js +46 -44
  115. package/dist/labels.js +1 -1
  116. package/dist/layout/FullscreenButton.svelte +33 -0
  117. package/dist/layout/FullscreenButton.svelte.d.ts +10 -0
  118. package/dist/layout/FullscreenToggle.svelte +8 -14
  119. package/dist/layout/ViewerChrome.svelte +116 -0
  120. package/dist/layout/ViewerChrome.svelte.d.ts +17 -0
  121. package/dist/layout/fullscreen.d.ts +4 -0
  122. package/dist/layout/fullscreen.svelte.d.ts +8 -0
  123. package/dist/layout/fullscreen.svelte.js +37 -0
  124. package/dist/layout/index.d.ts +3 -0
  125. package/dist/layout/index.js +3 -0
  126. package/dist/math.d.ts +7 -3
  127. package/dist/math.js +18 -21
  128. package/dist/overlays/index.d.ts +4 -0
  129. package/dist/periodic-table/PeriodicTable.svelte +9 -8
  130. package/dist/phase-diagram/IsobaricBinaryPhaseDiagram.svelte.d.ts +1 -1
  131. package/dist/phase-diagram/PhaseDiagramControls.svelte +3 -2
  132. package/dist/phase-diagram/PhaseDiagramControls.svelte.d.ts +4 -3
  133. package/dist/phase-diagram/PhaseDiagramEditorPane.svelte +2 -1
  134. package/dist/phase-diagram/PhaseDiagramEditorPane.svelte.d.ts +2 -3
  135. package/dist/phase-diagram/PhaseDiagramExportPane.svelte +47 -132
  136. package/dist/phase-diagram/PhaseDiagramExportPane.svelte.d.ts +3 -4
  137. package/dist/phase-diagram/colors.js +1 -1
  138. package/dist/phase-diagram/parse.d.ts +2 -1
  139. package/dist/plot/bar/BarPlot.svelte +79 -316
  140. package/dist/plot/bar/BarPlot.svelte.d.ts +7 -15
  141. package/dist/plot/bar/BarPlotControls.svelte.d.ts +1 -1
  142. package/dist/plot/bar/SpacegroupBarPlot.svelte +2 -1
  143. package/dist/plot/box/BoxPlot.svelte +76 -246
  144. package/dist/plot/box/BoxPlot.svelte.d.ts +4 -3
  145. package/dist/plot/box/BoxPlotControls.svelte.d.ts +1 -1
  146. package/dist/plot/box/Violin.svelte.d.ts +1 -1
  147. package/dist/plot/box/box-plot.d.ts +3 -2
  148. package/dist/plot/box/box-plot.js +5 -2
  149. package/dist/plot/box/kde.d.ts +2 -1
  150. package/dist/plot/box/kde.js +4 -4
  151. package/dist/plot/core/auto-place.d.ts +1 -1
  152. package/dist/plot/core/auto-place.js +4 -1
  153. package/dist/plot/core/components/ColorBar.svelte +5 -5
  154. package/dist/plot/core/components/ColorBar.svelte.d.ts +5 -4
  155. package/dist/plot/core/components/Line.svelte +3 -2
  156. package/dist/plot/core/components/Line.svelte.d.ts +3 -2
  157. package/dist/plot/core/components/PlotAxis.svelte +2 -1
  158. package/dist/plot/core/components/PlotAxis.svelte.d.ts +2 -1
  159. package/dist/plot/core/components/PlotControls.svelte.d.ts +1 -1
  160. package/dist/plot/core/components/ReferenceLine3D.svelte +2 -2
  161. package/dist/plot/core/components/ReferenceLine3D.svelte.d.ts +4 -4
  162. package/dist/plot/core/components/ReferencePlane.svelte +2 -2
  163. package/dist/plot/core/components/ReferencePlane.svelte.d.ts +4 -4
  164. package/dist/plot/core/data-cleaning.js +18 -18
  165. package/dist/plot/core/fill-utils.d.ts +4 -3
  166. package/dist/plot/core/fill-utils.js +6 -3
  167. package/dist/plot/core/interactions.d.ts +5 -1
  168. package/dist/plot/core/interactions.js +14 -0
  169. package/dist/plot/core/pan-zoom.svelte.d.ts +35 -0
  170. package/dist/plot/core/pan-zoom.svelte.js +221 -0
  171. package/dist/plot/core/placed-tween.svelte.d.ts +21 -0
  172. package/dist/plot/core/placed-tween.svelte.js +68 -0
  173. package/dist/plot/core/reference-line.d.ts +10 -10
  174. package/dist/plot/core/reference-line.js +6 -6
  175. package/dist/plot/core/scales.d.ts +17 -25
  176. package/dist/plot/core/scales.js +10 -8
  177. package/dist/plot/core/svg.d.ts +2 -1
  178. package/dist/plot/core/types.d.ts +18 -7
  179. package/dist/plot/core/utils/label-placement.d.ts +1 -1
  180. package/dist/plot/core/utils/label-placement.js +3 -3
  181. package/dist/plot/core/utils.d.ts +2 -1
  182. package/dist/plot/histogram/Histogram.svelte +77 -314
  183. package/dist/plot/histogram/HistogramControls.svelte.d.ts +1 -1
  184. package/dist/plot/sankey/Sankey.svelte +2 -5
  185. package/dist/plot/sankey/Sankey.svelte.d.ts +1 -1
  186. package/dist/plot/sankey/sankey.js +3 -1
  187. package/dist/plot/scatter/BinnedScatterPlot.svelte +3 -5
  188. package/dist/plot/scatter/BinnedScatterPlot.svelte.d.ts +4 -4
  189. package/dist/plot/scatter/ScatterPlot.svelte +160 -450
  190. package/dist/plot/scatter/ScatterPlot.svelte.d.ts +7 -15
  191. package/dist/plot/scatter/ScatterPlotControls.svelte.d.ts +1 -1
  192. package/dist/plot/scatter/binned-scatter-types.d.ts +4 -11
  193. package/dist/plot/scatter/index.d.ts +1 -1
  194. package/dist/plot/scatter-3d/ScatterPlot3D.svelte +15 -26
  195. package/dist/plot/scatter-3d/ScatterPlot3D.svelte.d.ts +6 -14
  196. package/dist/plot/scatter-3d/ScatterPlot3DControls.svelte +9 -10
  197. package/dist/plot/scatter-3d/ScatterPlot3DControls.svelte.d.ts +5 -5
  198. package/dist/plot/scatter-3d/ScatterPlot3DScene.svelte +122 -121
  199. package/dist/plot/scatter-3d/ScatterPlot3DScene.svelte.d.ts +5 -14
  200. package/dist/plot/scatter-3d/Surface3D.svelte +6 -5
  201. package/dist/plot/scatter-3d/Surface3D.svelte.d.ts +4 -3
  202. package/dist/plot/sunburst/Sunburst.svelte +16 -20
  203. package/dist/plot/sunburst/Sunburst.svelte.d.ts +4 -3
  204. package/dist/plot/sunburst/SunburstControls.svelte.d.ts +1 -1
  205. package/dist/plot/sunburst/sunburst.js +4 -1
  206. package/dist/rdf/RdfPlot.svelte.d.ts +1 -1
  207. package/dist/sanitize.js +13 -2
  208. package/dist/scene/SceneCamera.svelte +62 -0
  209. package/dist/scene/SceneCamera.svelte.d.ts +19 -0
  210. package/dist/scene/bind-renderer.svelte.d.ts +2 -0
  211. package/dist/scene/bind-renderer.svelte.js +14 -0
  212. package/dist/scene/index.d.ts +4 -0
  213. package/dist/scene/index.js +5 -0
  214. package/dist/scene/props.js +52 -0
  215. package/dist/scene/types.d.ts +26 -0
  216. package/dist/scene/types.js +1 -0
  217. package/dist/settings.d.ts +14 -2
  218. package/dist/settings.js +59 -1
  219. package/dist/spectral/Bands.svelte +8 -7
  220. package/dist/spectral/Bands.svelte.d.ts +3 -2
  221. package/dist/spectral/BandsAndDos.svelte +22 -24
  222. package/dist/spectral/BrillouinBandsDos.svelte +3 -3
  223. package/dist/spectral/Dos.svelte +5 -4
  224. package/dist/spectral/Dos.svelte.d.ts +2 -1
  225. package/dist/spectral/helpers.d.ts +6 -6
  226. package/dist/spectral/helpers.js +43 -37
  227. package/dist/state.svelte.d.ts +0 -7
  228. package/dist/state.svelte.js +0 -6
  229. package/dist/structure/Arrow.svelte +2 -4
  230. package/dist/structure/AtomLegend.svelte.d.ts +1 -1
  231. package/dist/structure/CanvasTooltip.svelte +1 -0
  232. package/dist/structure/CellSelect.svelte +11 -3
  233. package/dist/structure/CellSelect.svelte.d.ts +2 -1
  234. package/dist/structure/Lattice.svelte +2 -2
  235. package/dist/structure/Structure.svelte +291 -355
  236. package/dist/structure/Structure.svelte.d.ts +5 -15
  237. package/dist/structure/StructureControls.svelte +217 -2
  238. package/dist/structure/StructureControls.svelte.d.ts +5 -3
  239. package/dist/structure/StructureExportPane.svelte +54 -156
  240. package/dist/structure/StructureExportPane.svelte.d.ts +4 -5
  241. package/dist/structure/StructureInfoPane.svelte +5 -3
  242. package/dist/structure/StructureInfoPane.svelte.d.ts +5 -5
  243. package/dist/structure/StructureScene.svelte +365 -198
  244. package/dist/structure/StructureScene.svelte.d.ts +22 -20
  245. package/dist/structure/{label-placement.d.ts → atom-label-placement.d.ts} +3 -3
  246. package/dist/structure/{label-placement.js → atom-label-placement.js} +12 -2
  247. package/dist/structure/atom-properties.d.ts +1 -1
  248. package/dist/structure/atom-properties.js +11 -16
  249. package/dist/structure/bond-order-perception.js +2 -4
  250. package/dist/structure/bonding.d.ts +3 -0
  251. package/dist/structure/bonding.js +91 -48
  252. package/dist/structure/export.d.ts +24 -4
  253. package/dist/structure/export.js +64 -122
  254. package/dist/structure/index.d.ts +2 -0
  255. package/dist/structure/index.js +2 -0
  256. package/dist/structure/parse.d.ts +3 -2
  257. package/dist/structure/parse.js +333 -370
  258. package/dist/structure/partial-occupancy.d.ts +0 -1
  259. package/dist/structure/partial-occupancy.js +1 -1
  260. package/dist/structure/pbc.d.ts +1 -1
  261. package/dist/structure/pbc.js +186 -13
  262. package/dist/structure/polyhedra.d.ts +41 -0
  263. package/dist/structure/polyhedra.js +602 -0
  264. package/dist/structure/site.d.ts +4 -0
  265. package/dist/structure/site.js +1 -0
  266. package/dist/structure/supercell.js +3 -2
  267. package/dist/structure/validation.js +5 -6
  268. package/dist/symmetry/SymmetryElementControls.svelte +69 -0
  269. package/dist/symmetry/SymmetryElementControls.svelte.d.ts +9 -0
  270. package/dist/symmetry/SymmetryElements.svelte +354 -0
  271. package/dist/symmetry/SymmetryElements.svelte.d.ts +24 -0
  272. package/dist/symmetry/SymmetryStats.svelte +111 -6
  273. package/dist/symmetry/WyckoffTable.svelte +68 -7
  274. package/dist/symmetry/WyckoffTable.svelte.d.ts +3 -0
  275. package/dist/symmetry/cell-transform.js +7 -14
  276. package/dist/symmetry/index.d.ts +14 -4
  277. package/dist/symmetry/index.js +301 -80
  278. package/dist/symmetry/spacegroups.d.ts +5 -1
  279. package/dist/symmetry/spacegroups.js +15 -1
  280. package/dist/symmetry/symmetry-elements.d.ts +33 -0
  281. package/dist/symmetry/symmetry-elements.js +521 -0
  282. package/dist/symmetry/wyckoff-db.d.ts +9 -0
  283. package/dist/symmetry/wyckoff-db.js +87 -0
  284. package/dist/table/HeatmapTable.svelte +4 -15
  285. package/dist/table/HeatmapTable.svelte.d.ts +1 -1
  286. package/dist/trajectory/Trajectory.svelte +58 -61
  287. package/dist/trajectory/Trajectory.svelte.d.ts +10 -22
  288. package/dist/trajectory/TrajectoryExportPane.svelte +15 -24
  289. package/dist/trajectory/TrajectoryExportPane.svelte.d.ts +4 -5
  290. package/dist/trajectory/TrajectoryInfoPane.svelte +3 -2
  291. package/dist/trajectory/TrajectoryInfoPane.svelte.d.ts +3 -2
  292. package/dist/trajectory/constants.js +6 -2
  293. package/dist/trajectory/extract.js +17 -37
  294. package/dist/trajectory/format-detect.d.ts +0 -1
  295. package/dist/trajectory/format-detect.js +3 -9
  296. package/dist/trajectory/frame-reader.d.ts +0 -1
  297. package/dist/trajectory/frame-reader.js +62 -128
  298. package/dist/trajectory/helpers.d.ts +10 -2
  299. package/dist/trajectory/helpers.js +56 -36
  300. package/dist/trajectory/parse/ase.d.ts +9 -1
  301. package/dist/trajectory/parse/ase.js +47 -32
  302. package/dist/trajectory/parse/diagnostics.d.ts +3 -0
  303. package/dist/trajectory/parse/diagnostics.js +14 -0
  304. package/dist/trajectory/parse/index.d.ts +1 -1
  305. package/dist/trajectory/parse/index.js +54 -102
  306. package/dist/trajectory/parse/lammps.d.ts +0 -2
  307. package/dist/trajectory/parse/lammps.js +8 -6
  308. package/dist/trajectory/parse/pymatgen.d.ts +2 -0
  309. package/dist/trajectory/parse/pymatgen.js +74 -0
  310. package/dist/trajectory/parse/vasp.js +4 -3
  311. package/dist/trajectory/parse/xyz.d.ts +9 -21
  312. package/dist/trajectory/parse/xyz.js +28 -33
  313. package/dist/trajectory/plotting.d.ts +0 -1
  314. package/dist/trajectory/plotting.js +3 -100
  315. package/dist/utils.d.ts +1 -0
  316. package/dist/utils.js +1 -1
  317. package/dist/xrd/XrdPlot.svelte +14 -29
  318. package/dist/xrd/broadening.d.ts +2 -1
  319. package/dist/xrd/calc-xrd.js +6 -11
  320. package/dist/xrd/index.d.ts +2 -2
  321. package/package.json +29 -16
  322. package/dist/element/data.json +0 -11864
  323. package/dist/fermi-surface/marching-cubes.d.ts +0 -2
  324. package/dist/fermi-surface/marching-cubes.js +0 -2
  325. package/dist/plot/core/hover-lock.svelte.d.ts +0 -14
  326. package/dist/plot/core/hover-lock.svelte.js +0 -45
@@ -1,14 +1,43 @@
1
1
  import { COMPRESSION_EXTENSIONS_REGEX, CONFIG_DIRS_REGEX, STRUCT_KEYWORDS_REGEX, STRUCT_KEYWORDS_STRICT_REGEX, STRUCTURE_EXTENSIONS_REGEX, TRAJ_KEYWORDS_REGEX, VASP_FILES_REGEX, XYZ_EXTXYZ_REGEX, } from '../constants';
2
- import { ELEM_SYMBOLS } from '../labels';
2
+ import { FALLBACK_ELEMENTS, is_elem_symbol } from '../element';
3
+ import { strip_compression_extensions } from '../io';
3
4
  import * as math from '../math';
4
5
  import { wrap_to_unit_cell } from './pbc';
5
- import { normalize_scientific_notation } from '../utils';
6
+ import { make_site } from './site';
7
+ import { iter_xyz_frames } from '../trajectory/helpers';
8
+ import { normalize_scientific_notation, to_error } from '../utils';
6
9
  import { load as yaml_load } from 'js-yaml';
10
+ // === Parse error contract ===
11
+ // Individual format parsers (parse_poscar, parse_cif, parse_xyz, parse_phonopy_yaml,
12
+ // parse_optimade_json, ...) return `T | null` on failure and record failure reasons in a
13
+ // module-level collector (mirrored to the console). The top-level entry points
14
+ // parse_structure_file and parse_any_structure reset the collector on entry and THROW a
15
+ // descriptive Error aggregating the recorded reasons when nothing parses, so failure
16
+ // causes can reach the UI (callers surface error.message). Warnings (element-symbol
17
+ // fallbacks, skipped atoms, ...) never fail a parse and only go to the console.
18
+ let parse_errors = [];
19
+ const reset_parse_diagnostics = () => {
20
+ parse_errors = [];
21
+ };
22
+ // Record a failure reason; with `error` present, logs in `console.error('msg:', error)` form
23
+ const diag_error = (message, error) => {
24
+ const detail = error === undefined ? `` : `: ${to_error(error).message}`;
25
+ parse_errors.push(`${message}${detail}`);
26
+ if (error === undefined)
27
+ console.error(message);
28
+ else
29
+ console.error(`${message}:`, error);
30
+ };
31
+ const diag_warn = (message) => console.warn(message);
32
+ // Aggregate recorded failure reasons into the Error thrown by top-level entry points
33
+ const aggregate_parse_error = (filename) => {
34
+ const reasons = [...new Set(parse_errors)];
35
+ const detail = reasons.length ? `: ${reasons.join(`; `)}` : ``;
36
+ return new Error(`Failed to parse structure${filename ? ` from '${filename}'` : ``}${detail}`);
37
+ };
7
38
  const cif_coords_key = (coords) => `${coords[0].toFixed(6)},${coords[1].toFixed(6)},${coords[2].toFixed(6)}`;
8
39
  const cif_site_key = (element, abc, label) => `${element}|${label}|${cif_coords_key(abc)}`;
9
40
  const clone_structure_properties = (properties) => structuredClone(properties);
10
- const FALLBACK_ELEMENTS = [`H`, `He`, `Li`, `Be`, `B`, `C`, `N`, `O`, `F`, `Ne`];
11
- const is_known_element_symbol = (symbol) => ELEM_SYMBOLS.includes(symbol);
12
41
  const vec3_from_values = (values, context) => {
13
42
  if (values?.length !== 3) {
14
43
  throw new Error(`Invalid ${context}: expected 3 coordinates, got ${values?.length ?? 0}`);
@@ -54,11 +83,11 @@ function parse_coordinate_line(line) {
54
83
  function validate_element_symbol(symbol, index) {
55
84
  // Clean symbol (remove suffixes like _pv, /hash)
56
85
  const clean_symbol = symbol.split(/[_/]/)[0];
57
- if (is_known_element_symbol(clean_symbol))
86
+ if (is_elem_symbol(clean_symbol))
58
87
  return clean_symbol;
59
88
  // Fallback to default elements by atomic number
60
89
  const fallback = FALLBACK_ELEMENTS[index % FALLBACK_ELEMENTS.length] ?? `H`;
61
- console.warn(`Invalid element symbol '${symbol}', using fallback '${fallback}'`);
90
+ diag_warn(`Invalid element symbol '${symbol}', using fallback '${fallback}'`);
62
91
  return fallback;
63
92
  }
64
93
  // Per OPTIMADE spec, species_at_sites holds species NAMES (e.g. 'Si1') resolved via the
@@ -70,7 +99,7 @@ function resolve_optimade_element(species_name, species_list, index) {
70
99
  const spec = species_list?.find((entry) => entry.name === species_name);
71
100
  let best;
72
101
  for (const [sym_idx, symbol] of (spec?.chemical_symbols ?? []).entries()) {
73
- if (!is_known_element_symbol(symbol))
102
+ if (!is_elem_symbol(symbol))
74
103
  continue;
75
104
  const conc = spec?.concentration?.[sym_idx] ?? 0;
76
105
  if (!best || conc > best.conc)
@@ -81,7 +110,7 @@ function resolve_optimade_element(species_name, species_list, index) {
81
110
  // Fallback: the name may be an element with a trailing atom index (e.g. 'O1');
82
111
  // element symbols never contain digits, so stripping them is safe
83
112
  const stripped = species_name.replace(/\d+$/, ``);
84
- if (is_known_element_symbol(stripped))
113
+ if (is_elem_symbol(stripped))
85
114
  return { symbol: stripped, sym_idx: -1 };
86
115
  return { symbol: validate_element_symbol(species_name, index), sym_idx: -1 };
87
116
  }
@@ -98,20 +127,39 @@ const approximate_cart_to_frac = (xyz, axis_lengths) => [
98
127
  Math.abs(axis_lengths[1]) > math.EPS ? xyz[1] / axis_lengths[1] : 0,
99
128
  Math.abs(axis_lengths[2]) > math.EPS ? xyz[2] / axis_lengths[2] : 0,
100
129
  ];
101
- // Parse VASP POSCAR file format
130
+ // Build a 3x3 matrix from 3 row vectors; error context is suffixed with the 1-based row index
131
+ const matrix3x3_from_rows = (rows, context) => [
132
+ vec3_from_values(rows[0], `${context} 1`),
133
+ vec3_from_values(rows[1], `${context} 2`),
134
+ vec3_from_values(rows[2], `${context} 3`),
135
+ ];
136
+ // cart→frac converter that falls back to per-axis-length division for singular lattices.
137
+ // axis_lengths defaults to the row norms of the lattice matrix.
138
+ const cart_to_frac_with_fallback = (matrix, axis_lengths) => {
139
+ const exact_converter = try_create_cart_to_frac(matrix);
140
+ if (exact_converter)
141
+ return { convert: exact_converter, exact: true };
142
+ const lengths = axis_lengths ?? [
143
+ Math.hypot(...matrix[0]),
144
+ Math.hypot(...matrix[1]),
145
+ Math.hypot(...matrix[2]),
146
+ ];
147
+ return { convert: (xyz) => approximate_cart_to_frac(xyz, lengths), exact: false };
148
+ };
149
+ // @internal parser exported for tests; public entry points: parse_structure_file/parse_any_structure. Parse VASP POSCAR.
102
150
  export function parse_poscar(content) {
103
151
  try {
104
152
  // Strip only horizontal whitespace: a blank first (comment) line is valid POSCAR
105
153
  const lines = content.replace(/^[ \t]+/, ``).split(/\r?\n/);
106
154
  if (lines.length < 8) {
107
- console.error(`POSCAR file too short`);
155
+ diag_error(`POSCAR file too short`);
108
156
  return null;
109
157
  }
110
158
  // Scale line: one value (negative = target volume) or three per-axis Cartesian factors
111
159
  const scale_tokens = lines[1].trim().split(/\s+/).map(parseFloat);
112
160
  let scale_factor = scale_tokens[0];
113
161
  if (isNaN(scale_factor)) {
114
- console.error(`Invalid scaling factor in POSCAR`);
162
+ diag_error(`Invalid scaling factor in POSCAR`);
115
163
  return null;
116
164
  }
117
165
  const scale_vec = scale_tokens.slice(0, 3);
@@ -129,6 +177,10 @@ export function parse_poscar(content) {
129
177
  // Handle negative scale factor (volume-based scaling, single-factor form only)
130
178
  if (!per_axis_scale && scale_factor < 0) {
131
179
  const volume = Math.abs(math.det_3x3(lattice_vecs));
180
+ if (volume < math.EPS) {
181
+ diag_error(`POSCAR target-volume scaling requires a non-singular lattice`);
182
+ return null;
183
+ }
132
184
  scale_factor = (-scale_factor / volume) ** (1 / 3);
133
185
  }
134
186
  // Scale lattice vectors (per-axis factors multiply Cartesian components)
@@ -183,136 +235,104 @@ export function parse_poscar(content) {
183
235
  line_index += 1;
184
236
  }
185
237
  if (element_symbols.length !== atom_counts.length) {
186
- console.error(`Mismatch between element symbols and atom counts`);
238
+ diag_error(`Mismatch between element symbols and atom counts`);
239
+ return null;
240
+ }
241
+ if (line_index >= lines.length) {
242
+ diag_error(`Missing coordinate mode line in POSCAR`);
187
243
  return null;
188
244
  }
189
245
  // Check for selective dynamics
190
246
  let has_selective_dynamics = false;
191
- if (line_index < lines.length) {
192
- let coordinate_mode = lines[line_index].trim().toUpperCase();
193
- if (coordinate_mode.startsWith(`S`)) {
194
- has_selective_dynamics = true;
195
- line_index += 1;
196
- if (line_index < lines.length) {
197
- coordinate_mode = lines[line_index].trim().toUpperCase();
198
- }
199
- else {
200
- console.error(`Missing coordinate mode after selective dynamics`);
201
- return null;
202
- }
247
+ let coordinate_mode = lines[line_index].trim().toUpperCase();
248
+ if (coordinate_mode.startsWith(`S`)) {
249
+ has_selective_dynamics = true;
250
+ line_index += 1;
251
+ if (line_index < lines.length) {
252
+ coordinate_mode = lines[line_index].trim().toUpperCase();
203
253
  }
204
- // Determine coordinate mode
205
- const is_direct = coordinate_mode.startsWith(`D`);
206
- const is_cartesian = coordinate_mode.startsWith(`C`) || coordinate_mode.startsWith(`K`);
207
- if (!is_direct && !is_cartesian) {
208
- console.error(`Unknown coordinate mode in POSCAR: ${coordinate_mode}`);
254
+ else {
255
+ diag_error(`Missing coordinate mode after selective dynamics`);
209
256
  return null;
210
257
  }
211
- // Parse atomic positions
212
- const poscar_axis_lengths = [
213
- Math.hypot(...scaled_lattice[0]),
214
- Math.hypot(...scaled_lattice[1]),
215
- Math.hypot(...scaled_lattice[2]),
216
- ];
217
- const poscar_frac_to_cart = math.create_frac_to_cart(scaled_lattice);
218
- const poscar_cart_to_frac = try_create_cart_to_frac(scaled_lattice);
219
- if (!is_direct && !poscar_cart_to_frac) {
220
- console.warn(`POSCAR: singular lattice, using axis-length fallback for cart→frac`);
221
- }
222
- const sites = [];
223
- let atom_index = 0;
224
- for (let elem_idx = 0; elem_idx < element_symbols.length; elem_idx++) {
225
- const element = validate_element_symbol(element_symbols[elem_idx], elem_idx);
226
- const count = atom_counts[elem_idx];
227
- for (let atom_count_idx = 0; atom_count_idx < count; atom_count_idx++) {
228
- const coord_line_idx = line_index + 1 + atom_index + atom_count_idx;
229
- if (coord_line_idx >= lines.length) {
230
- console.error(`Not enough coordinate lines in POSCAR`);
231
- return null;
232
- }
233
- const coords = vec3_from_values(parse_coordinate_line(lines[coord_line_idx]), `POSCAR atom coordinates on line ${coord_line_idx + 1}`);
234
- // Parse selective dynamics if present
235
- let selective_dynamics;
236
- if (has_selective_dynamics) {
237
- const tokens = lines[coord_line_idx].trim().split(/\s+/);
238
- if (tokens.length >= 6) {
239
- selective_dynamics = [tokens[3] === `T`, tokens[4] === `T`, tokens[5] === `T`];
240
- }
258
+ }
259
+ // Determine coordinate mode
260
+ const is_direct = coordinate_mode.startsWith(`D`);
261
+ const is_cartesian = coordinate_mode.startsWith(`C`) || coordinate_mode.startsWith(`K`);
262
+ if (!is_direct && !is_cartesian) {
263
+ diag_error(`Unknown coordinate mode in POSCAR: ${coordinate_mode}`);
264
+ return null;
265
+ }
266
+ // Parse atomic positions
267
+ const poscar_frac_to_cart = math.create_frac_to_cart(scaled_lattice);
268
+ const poscar_cart_to_frac = cart_to_frac_with_fallback(scaled_lattice);
269
+ if (!is_direct && !poscar_cart_to_frac.exact) {
270
+ diag_warn(`POSCAR: singular lattice, using axis-length fallback for cart→frac`);
271
+ }
272
+ const sites = [];
273
+ let atom_index = 0;
274
+ for (let elem_idx = 0; elem_idx < element_symbols.length; elem_idx++) {
275
+ const element = validate_element_symbol(element_symbols[elem_idx], elem_idx);
276
+ const count = atom_counts[elem_idx];
277
+ for (let atom_count_idx = 0; atom_count_idx < count; atom_count_idx++) {
278
+ const coord_line_idx = line_index + 1 + atom_index + atom_count_idx;
279
+ if (coord_line_idx >= lines.length) {
280
+ diag_error(`Not enough coordinate lines in POSCAR`);
281
+ return null;
282
+ }
283
+ const coords = vec3_from_values(parse_coordinate_line(lines[coord_line_idx]), `POSCAR atom coordinates on line ${coord_line_idx + 1}`);
284
+ // Parse selective dynamics if present
285
+ let selective_dynamics;
286
+ if (has_selective_dynamics) {
287
+ const tokens = lines[coord_line_idx].trim().split(/\s+/);
288
+ if (tokens.length >= 6) {
289
+ selective_dynamics = [tokens[3] === `T`, tokens[4] === `T`, tokens[5] === `T`];
241
290
  }
242
- // Cartesian input is scaled then converted to fractional (axis-length fallback
243
- // for singular lattices); abc wraps to [0, 1) and xyz is recomputed from it so
244
- // both stay consistent (singular Cartesian keeps the scaled input as xyz)
245
- const cart = is_direct ? null : apply_axis_scale(coords);
246
- const raw_abc = cart
247
- ? (poscar_cart_to_frac?.(cart) ??
248
- approximate_cart_to_frac(cart, poscar_axis_lengths))
249
- : coords;
250
- const abc = wrap_to_unit_cell(raw_abc);
251
- const xyz = cart && !poscar_cart_to_frac ? cart : poscar_frac_to_cart(abc);
252
- const site = {
253
- species: [{ element, occu: 1, oxidation_state: 0 }],
254
- abc,
255
- xyz,
256
- label: `${element}${atom_index + atom_count_idx + 1}`,
257
- properties: selective_dynamics ? { selective_dynamics } : {},
258
- };
259
- sites.push(site);
260
291
  }
261
- atom_index += count;
292
+ // Cartesian input is scaled then converted to fractional (axis-length fallback
293
+ // for singular lattices); abc wraps to [0, 1) and xyz is recomputed from it so
294
+ // both stay consistent (singular Cartesian keeps the scaled input as xyz)
295
+ const cart = is_direct ? null : apply_axis_scale(coords);
296
+ const raw_abc = cart ? poscar_cart_to_frac.convert(cart) : coords;
297
+ const abc = wrap_to_unit_cell(raw_abc);
298
+ const xyz = cart && !poscar_cart_to_frac.exact ? cart : poscar_frac_to_cart(abc);
299
+ sites.push(make_site(element, abc, xyz, `${element}${atom_index + atom_count_idx + 1}`, selective_dynamics ? { selective_dynamics } : {}));
262
300
  }
263
- const lattice_params = math.calc_lattice_params(scaled_lattice);
264
- const structure = {
265
- sites,
266
- lattice: { matrix: scaled_lattice, ...lattice_params },
267
- };
268
- return structure;
301
+ atom_index += count;
269
302
  }
270
- console.error(`Missing coordinate mode line in POSCAR`);
271
- return null;
303
+ const lattice_params = math.calc_lattice_params(scaled_lattice);
304
+ return { sites, lattice: { matrix: scaled_lattice, ...lattice_params } };
272
305
  }
273
306
  catch (error) {
274
- console.error(`Error parsing POSCAR file:`, error);
307
+ diag_error(`Error parsing POSCAR file`, error);
275
308
  return null;
276
309
  }
277
310
  }
278
- // Parse XYZ file format. Supports both standard XYZ and extended XYZ formats with multi-frame support
311
+ // @internal parser exported for tests + trajectory parser; public entry points: parse_structure_file/parse_any_structure. Parse standard/extended XYZ (multi-frame).
279
312
  export function parse_xyz(content) {
280
313
  try {
281
314
  const normalized_content = content.trim();
282
315
  if (!normalized_content) {
283
- console.error(`Empty XYZ file`);
316
+ diag_error(`Empty XYZ file`);
284
317
  return null;
285
318
  }
286
- // Split into frames by reading the atom count and slicing lines
319
+ // Walk frames by reading atom counts; multi-frame XYZ parses only the last frame
287
320
  const all_lines = normalized_content.split(/\r?\n/);
288
- const frames = [];
289
- let frame_line_idx = 0;
290
- while (frame_line_idx < all_lines.length) {
291
- const numAtoms = parseInt(all_lines[frame_line_idx].trim(), 10);
292
- if (!isNaN(numAtoms) &&
293
- numAtoms > 0 &&
294
- frame_line_idx + numAtoms + 1 < all_lines.length) {
295
- const frameLines = all_lines.slice(frame_line_idx, frame_line_idx + numAtoms + 2);
296
- frames.push(frameLines.join(`\n`));
297
- frame_line_idx += numAtoms + 2;
298
- }
299
- else
300
- frame_line_idx++;
301
- }
302
- // If no frames found, try simple parsing
303
- if (frames.length === 0)
304
- frames.push(normalized_content);
305
- // Parse the last frame (or only frame)
306
- const frame_content = frames.at(-1) ?? ``;
307
- const lines = frame_content.trim().split(/\r?\n/);
321
+ let last_frame = null;
322
+ for (const frame of iter_xyz_frames(all_lines))
323
+ last_frame = frame;
324
+ // If no complete frame found, fall back to parsing the whole content as one frame
325
+ const lines = last_frame
326
+ ? all_lines.slice(last_frame.start, last_frame.start + last_frame.num_atoms + 2)
327
+ : all_lines;
308
328
  if (lines.length < 2) {
309
- console.error(`XYZ frame too short`);
329
+ diag_error(`XYZ frame too short`);
310
330
  return null;
311
331
  }
312
332
  // Parse number of atoms (line 1)
313
333
  const num_atoms = parseInt(lines[0].trim(), 10);
314
334
  if (isNaN(num_atoms) || num_atoms <= 0) {
315
- console.error(`Invalid number of atoms in XYZ file`);
335
+ diag_error(`Invalid number of atoms in XYZ file`);
316
336
  return null;
317
337
  }
318
338
  // Parse comment line (line 2) - may contain lattice info for extended XYZ
@@ -323,61 +343,54 @@ export function parse_xyz(content) {
323
343
  if (lattice_match) {
324
344
  const lattice_values = lattice_match[1].split(/\s+/).map(parse_coordinate);
325
345
  if (lattice_values.length === 9) {
326
- const lattice_vectors = [
327
- vec3_from_values(lattice_values.slice(0, 3), `XYZ lattice vector 1`),
328
- vec3_from_values(lattice_values.slice(3, 6), `XYZ lattice vector 2`),
329
- vec3_from_values(lattice_values.slice(6, 9), `XYZ lattice vector 3`),
330
- ];
346
+ const lattice_vectors = matrix3x3_from_rows([lattice_values.slice(0, 3), lattice_values.slice(3, 6), lattice_values.slice(6, 9)], `XYZ lattice vector`);
331
347
  const lattice_params = math.calc_lattice_params(lattice_vectors);
332
348
  lattice = { matrix: lattice_vectors, ...lattice_params };
333
349
  }
334
350
  }
335
351
  // Parse atomic coordinates (starting from line 3)
336
- const xyz_axis_lengths = lattice ? [lattice.a, lattice.b, lattice.c] : null;
337
352
  let xyz_frac_to_cart = null;
338
353
  let xyz_cart_to_frac = null;
339
354
  if (lattice) {
340
355
  xyz_frac_to_cart = math.create_frac_to_cart(lattice.matrix);
341
- xyz_cart_to_frac = try_create_cart_to_frac(lattice.matrix);
356
+ xyz_cart_to_frac = cart_to_frac_with_fallback(lattice.matrix, [
357
+ lattice.a,
358
+ lattice.b,
359
+ lattice.c,
360
+ ]).convert;
342
361
  }
343
362
  const sites = [];
344
363
  for (let atom_idx = 0; atom_idx < num_atoms; atom_idx++) {
345
364
  const line_idx = atom_idx + 2;
346
365
  if (line_idx >= lines.length) {
347
- console.error(`Not enough coordinate lines in XYZ file`);
366
+ diag_error(`Not enough coordinate lines in XYZ file`);
348
367
  return null;
349
368
  }
350
369
  const parts = lines[line_idx].trim().split(/\s+/);
351
370
  if (parts.length < 4) {
352
- console.error(`Invalid coordinate line in XYZ file`);
371
+ diag_error(`Invalid coordinate line in XYZ file`);
353
372
  return null;
354
373
  }
355
374
  const element = validate_element_symbol(parts[0], atom_idx);
356
375
  const xyz = vec3_from_values(parts.slice(1, 4).map(parse_coordinate), `XYZ atom position ${atom_idx + 1}`);
357
376
  // Calculate fractional coordinates if lattice is available
358
377
  let abc = [0, 0, 0];
359
- if (lattice && xyz_frac_to_cart && xyz_axis_lengths) {
360
- abc = xyz_cart_to_frac
361
- ? xyz_cart_to_frac(xyz)
362
- : approximate_cart_to_frac(xyz, xyz_axis_lengths);
378
+ if (lattice && xyz_frac_to_cart && xyz_cart_to_frac) {
363
379
  // Ensure fractional coordinates are wrapped into [0, 1) for consistency
364
- abc = wrap_to_unit_cell(abc);
380
+ abc = wrap_to_unit_cell(xyz_cart_to_frac(xyz));
365
381
  // Keep rendered atoms inside primary unit cell by recomputing xyz
366
382
  const wrapped_xyz = xyz_frac_to_cart(abc);
367
383
  xyz[0] = wrapped_xyz[0];
368
384
  xyz[1] = wrapped_xyz[1];
369
385
  xyz[2] = wrapped_xyz[2];
370
386
  }
371
- const species = [{ element, occu: 1, oxidation_state: 0 }];
372
- const label = `${element}${atom_idx + 1}`;
373
- const site = { species, abc, xyz, label, properties: {} };
374
- sites.push(site);
387
+ sites.push(make_site(element, abc, xyz, `${element}${atom_idx + 1}`));
375
388
  }
376
389
  const structure = { sites, ...(lattice && { lattice }) };
377
390
  return structure;
378
391
  }
379
392
  catch (error) {
380
- console.error(`Error parsing XYZ file:`, error);
393
+ diag_error(`Error parsing XYZ file`, error);
381
394
  return null;
382
395
  }
383
396
  }
@@ -538,6 +551,23 @@ const build_cif_atom_site_header_indices = (headers) => {
538
551
  });
539
552
  return indices;
540
553
  };
554
+ // Walk CIF loop_ blocks: yields each loop's header tags plus the index of its first data line
555
+ function* iter_cif_loops(lines) {
556
+ for (let idx = 0; idx < lines.length; idx++) {
557
+ if (lines[idx].trim() !== `loop_`)
558
+ continue;
559
+ const headers = [];
560
+ let jj = idx + 1;
561
+ while (jj < lines.length && lines[jj].trim().startsWith(`_`)) {
562
+ headers.push(lines[jj].trim());
563
+ jj++;
564
+ }
565
+ yield { headers, data_start: jj };
566
+ }
567
+ }
568
+ // Split a CIF data line into whitespace-separated tokens, keeping quoted multi-word
569
+ // values as single tokens and stripping the quotes
570
+ const split_cif_tokens = (line) => (line.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? []).map((token) => token.replaceAll(/['"]/g, ``));
541
571
  // Parse atom data from CIF with robust error handling
542
572
  const parse_cif_atom_data = (raw_data, indices, coords_type) => {
543
573
  const { label = 0, symbol = -1, occupancy = -1 } = indices;
@@ -574,12 +604,12 @@ const parse_cif_atom_data = (raw_data, indices, coords_type) => {
574
604
  occupancy: occu,
575
605
  };
576
606
  };
577
- // Parse CIF (Crystallographic Information File) format
607
+ // @internal parser exported for tests; public entry points: parse_structure_file/parse_any_structure. Parse CIF (Crystallographic Information File).
578
608
  export function parse_cif(content, wrap_fractional_coords = true, strict = true) {
579
609
  try {
580
610
  const text = content.trim();
581
611
  if (!text) {
582
- console.error(`CIF file is empty`);
612
+ diag_error(`CIF file is empty`);
583
613
  return null;
584
614
  }
585
615
  // Find atom site loop that actually contains coordinates (fract or Cartn)
@@ -587,16 +617,8 @@ export function parse_cif(content, wrap_fractional_coords = true, strict = true)
587
617
  let atom_headers = [];
588
618
  const atom_data_lines = [];
589
619
  const symmetry_ops = [];
590
- for (let ii = 0; ii < lines.length; ii++) {
591
- if (lines[ii].trim() !== `loop_`)
592
- continue;
593
- let jj = ii + 1;
594
- const headers = [];
595
- // Collect headers for this loop
596
- while (jj < lines.length && lines[jj].trim().startsWith(`_`)) {
597
- headers.push(lines[jj].trim());
598
- jj++;
599
- }
620
+ for (const { headers, data_start } of iter_cif_loops(lines)) {
621
+ let jj = data_start;
600
622
  // Check if this is a symmetry operations loop
601
623
  if (headers.some((header) => header.includes(`_symmetry_equiv_pos_as_xyz`) ||
602
624
  header.includes(`_space_group_symop_operation_xyz`))) {
@@ -623,10 +645,8 @@ export function parse_cif(content, wrap_fractional_coords = true, strict = true)
623
645
  (indices_preview.cart_x !== undefined &&
624
646
  indices_preview.cart_y !== undefined &&
625
647
  indices_preview.cart_z !== undefined);
626
- if (!has_coords) {
627
- ii = jj - 1;
648
+ if (!has_coords)
628
649
  continue;
629
- }
630
650
  // This is the desired atom-site loop with coordinates: collect data lines
631
651
  atom_headers = headers;
632
652
  while (jj < lines.length) {
@@ -653,7 +673,7 @@ export function parse_cif(content, wrap_fractional_coords = true, strict = true)
653
673
  break;
654
674
  }
655
675
  if (atom_headers.length === 0 || atom_data_lines.length === 0) {
656
- console.error(`No valid atom site loop found in CIF file`);
676
+ diag_error(`No valid atom site loop found in CIF file`);
657
677
  return null;
658
678
  }
659
679
  // Parse atom data with error handling
@@ -669,7 +689,7 @@ export function parse_cif(content, wrap_fractional_coords = true, strict = true)
669
689
  ? `cart`
670
690
  : null;
671
691
  if (!coords_type) {
672
- console.error(`CIF atom site loop missing coordinates (fract or Cartn)`);
692
+ diag_error(`CIF atom site loop missing coordinates (fract or Cartn)`);
673
693
  return null;
674
694
  }
675
695
  // Collect required coordinate indices
@@ -677,12 +697,7 @@ export function parse_cif(content, wrap_fractional_coords = true, strict = true)
677
697
  ? [header_indices.x, header_indices.y, header_indices.z]
678
698
  : [header_indices.cart_x, header_indices.cart_y, header_indices.cart_z];
679
699
  const atoms = atom_data_lines
680
- .map((line) => {
681
- // Handle quoted multi-word values by splitting only on whitespace
682
- // that is not inside quotes.
683
- const tokens = line.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [];
684
- return tokens.map((token) => token.replaceAll(/['"]/g, ``));
685
- })
700
+ .map(split_cif_tokens)
686
701
  .filter((tokens) => {
687
702
  const { disorder } = header_indices;
688
703
  const max_required_idx = Math.max(...required_indices);
@@ -694,20 +709,20 @@ export function parse_cif(content, wrap_fractional_coords = true, strict = true)
694
709
  return parse_cif_atom_data(tokens, header_indices, coords_type);
695
710
  }
696
711
  catch (error) {
697
- console.warn(`Skipping invalid atom data: ${error}`);
712
+ diag_warn(`Skipping invalid atom data: ${error}`);
698
713
  return null;
699
714
  }
700
715
  })
701
716
  .filter((atom) => atom !== null);
702
717
  if (atoms.length === 0) {
703
- console.error(`No valid atoms found in CIF file`);
718
+ diag_error(`No valid atoms found in CIF file`);
704
719
  return null;
705
720
  }
706
721
  // Extract cell parameters and build lattice
707
722
  const lengths = extract_cif_cell_parameters(text, `cell_length`, strict);
708
723
  const angles = extract_cif_cell_parameters(text, `cell_angle`, strict);
709
724
  if (lengths.length < 3 || angles.length < 3) {
710
- console.error(`Insufficient cell parameters in CIF file`);
725
+ diag_error(`Insufficient cell parameters in CIF file`);
711
726
  return null;
712
727
  }
713
728
  // Build lattice and create sites
@@ -716,9 +731,9 @@ export function parse_cif(content, wrap_fractional_coords = true, strict = true)
716
731
  const lattice_matrix = math.cell_to_lattice_matrix(a, b, c, alpha, beta, gamma);
717
732
  const lattice_params = math.calc_lattice_params(lattice_matrix);
718
733
  const frac_to_cart = math.create_frac_to_cart(lattice_matrix);
719
- const cart_to_frac = try_create_cart_to_frac(lattice_matrix);
734
+ const cart_to_frac = cart_to_frac_with_fallback(lattice_matrix, [a, b, c]).convert;
720
735
  // Create sites with coordinate conversion and symmetry operations
721
- const wrap_vec3 = (v) => (wrap_fractional_coords ? wrap_to_unit_cell(v) : v);
736
+ const wrap_vec3 = (vec) => wrap_fractional_coords ? wrap_to_unit_cell(vec) : vec;
722
737
  // Apply symmetry operations to generate all equivalent positions
723
738
  const all_sites = [];
724
739
  // Normalize symmetry operations (trim/strip quotes) but preserve duplicates; we deduplicate positions later
@@ -728,48 +743,32 @@ export function parse_cif(content, wrap_fractional_coords = true, strict = true)
728
743
  // Rely on symmetry operations list for all centering/translations to avoid double-counting
729
744
  // TODO: Support conventional cells with centering by discovering centering from space group metadata
730
745
  // when present (e.g. P, I, F, C, R centering types)
731
- const centering_vectors = [[0, 0, 0]];
732
746
  // Inspect optional _atom_type_number_in_cell loop to see if atom sites are already expanded
733
- const atom_type_counts = (() => {
734
- const map = {};
735
- const text_lines = text.split(`\n`);
736
- for (let li = 0; li < text_lines.length; li++) {
737
- if (text_lines[li].trim() !== `loop_`) {
738
- continue;
739
- }
740
- let lj = li + 1;
741
- const hdrs = [];
742
- while (lj < text_lines.length && text_lines[lj].trim().startsWith(`_`)) {
743
- hdrs.push(text_lines[lj].trim().toLowerCase());
744
- lj++;
745
- }
746
- const sym_idx = hdrs.findIndex((hdr) => hdr.endsWith(`_atom_type_symbol`));
747
- const num_idx = hdrs.findIndex((hdr) => hdr.endsWith(`_atom_type_number_in_cell`));
748
- if (sym_idx !== -1 && num_idx !== -1) {
749
- while (lj < text_lines.length) {
750
- const line = text_lines[lj].trim();
751
- if (!line || line === `loop_` || line.startsWith(`data_`))
752
- break;
753
- if (line.startsWith(`#`)) {
754
- lj++;
755
- continue;
756
- }
757
- const toks = (line.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? []).map((tok) => tok.replaceAll(/['"]/g, ``));
758
- if (toks.length > Math.max(sym_idx, num_idx)) {
759
- // Normalize type symbol to bare element (e.g. 'Sn2+' -> 'Sn')
760
- const match = /^([A-Z][a-z]*)/.exec(toks[sym_idx]);
761
- const sym = match ? match[1] : toks[sym_idx];
762
- const num = parseInt(toks[num_idx], 10);
763
- if (sym && !Number.isNaN(num))
764
- map[sym] = num;
765
- }
766
- lj++;
767
- }
747
+ const atom_type_counts = {};
748
+ for (const { headers, data_start } of iter_cif_loops(lines)) {
749
+ const hdrs = headers.map((hdr) => hdr.toLowerCase());
750
+ const sym_idx = hdrs.findIndex((hdr) => hdr.endsWith(`_atom_type_symbol`));
751
+ const num_idx = hdrs.findIndex((hdr) => hdr.endsWith(`_atom_type_number_in_cell`));
752
+ if (sym_idx === -1 || num_idx === -1)
753
+ continue;
754
+ for (let lj = data_start; lj < lines.length; lj++) {
755
+ const line = lines[lj].trim();
756
+ if (!line || line === `loop_` || line.startsWith(`data_`))
768
757
  break;
758
+ if (line.startsWith(`#`))
759
+ continue;
760
+ const toks = split_cif_tokens(line);
761
+ if (toks.length > Math.max(sym_idx, num_idx)) {
762
+ // Normalize type symbol to bare element (e.g. 'Sn2+' -> 'Sn')
763
+ const match = /^([A-Z][a-z]*)/.exec(toks[sym_idx]);
764
+ const sym = match ? match[1] : toks[sym_idx];
765
+ const num = parseInt(toks[num_idx], 10);
766
+ if (sym && !Number.isNaN(num))
767
+ atom_type_counts[sym] = num;
769
768
  }
770
769
  }
771
- return map;
772
- })();
770
+ break;
771
+ }
773
772
  const observed_counts = {};
774
773
  for (const atom of atoms) {
775
774
  observed_counts[atom.element] = (observed_counts[atom.element] || 0) + 1;
@@ -794,41 +793,26 @@ export function parse_cif(content, wrap_fractional_coords = true, strict = true)
794
793
  }
795
794
  else {
796
795
  const xyz_base = [atom.coords[0], atom.coords[1], atom.coords[2]];
797
- const atom_abc = wrap_vec3(cart_to_frac
798
- ? cart_to_frac(xyz_base)
799
- : approximate_cart_to_frac(xyz_base, [a, b, c]));
796
+ const atom_abc = wrap_vec3(cart_to_frac(xyz_base));
800
797
  fractional_atom = { ...atom, coords: atom_abc, coords_type: `fract` };
801
798
  }
802
799
  // First apply symmetry operations in fractional space
803
800
  const equiv_atoms = apply_symmetry_ops(fractional_atom, ops_to_use, wrap_fractional_coords);
804
- // Then apply lattice centering shifts to each equivalent position
805
801
  for (const equiv_atom of equiv_atoms) {
806
- for (const cv of centering_vectors) {
807
- const abc = wrap_vec3([
808
- equiv_atom.coords[0] + cv[0],
809
- equiv_atom.coords[1] + cv[1],
810
- equiv_atom.coords[2] + cv[2],
811
- ]);
812
- const key = cif_site_key(element, abc, equiv_atom.id);
813
- if (seen_site_keys.has(key))
814
- continue;
815
- seen_site_keys.add(key);
816
- const xyz = frac_to_cart(abc);
817
- all_sites.push({
818
- species: [{ element, occu: equiv_atom.occupancy, oxidation_state: 0 }],
819
- abc,
820
- xyz,
821
- label: equiv_atom.id,
822
- properties: {},
823
- });
824
- }
802
+ const abc = wrap_vec3(equiv_atom.coords);
803
+ const key = cif_site_key(element, abc, equiv_atom.id);
804
+ if (seen_site_keys.has(key))
805
+ continue;
806
+ seen_site_keys.add(key);
807
+ const xyz = frac_to_cart(abc);
808
+ all_sites.push(make_site(element, abc, xyz, equiv_atom.id, {}, equiv_atom.occupancy));
825
809
  }
826
810
  }
827
811
  const sites = all_sites;
828
812
  return { sites, lattice: { matrix: lattice_matrix, ...lattice_params } };
829
813
  }
830
814
  catch (error) {
831
- console.error(`Error parsing CIF file:`, error);
815
+ diag_error(`Error parsing CIF file`, error);
832
816
  return null;
833
817
  }
834
818
  }
@@ -836,11 +820,7 @@ export function parse_cif(content, wrap_fractional_coords = true, strict = true)
836
820
  function convert_phonopy_cell(cell) {
837
821
  const sites = [];
838
822
  // Phonopy stores lattice vectors as rows, use them directly
839
- const lattice_matrix = [
840
- vec3_from_values(cell.lattice[0], `phonopy lattice vector 1`),
841
- vec3_from_values(cell.lattice[1], `phonopy lattice vector 2`),
842
- vec3_from_values(cell.lattice[2], `phonopy lattice vector 3`),
843
- ];
823
+ const lattice_matrix = matrix3x3_from_rows(cell.lattice, `phonopy lattice vector`);
844
824
  // Process each atomic site
845
825
  const phonopy_frac_to_cart = math.create_frac_to_cart(lattice_matrix);
846
826
  for (const point of cell.points) {
@@ -851,9 +831,7 @@ function convert_phonopy_cell(cell) {
851
831
  mass: point.mass,
852
832
  ...(point.reduced_to !== undefined && { reduced_to: point.reduced_to }),
853
833
  };
854
- const species = [{ element, occu: 1.0, oxidation_state: 0 }];
855
- const site = { species, abc, xyz, label: point.symbol, properties };
856
- sites.push(site);
834
+ sites.push(make_site(element, abc, xyz, point.symbol, properties));
857
835
  }
858
836
  // Calculate lattice parameters
859
837
  const calculated_lattice_params = math.calc_lattice_params(lattice_matrix);
@@ -872,7 +850,7 @@ const get_phonopy_cell = (data, cell_type) => {
872
850
  const cell = Reflect.get(data, cell_type);
873
851
  return is_phonopy_cell(cell) ? cell : undefined;
874
852
  };
875
- // Parse phonopy YAML file and return the requested cell type (or preferred single structure)
853
+ // @internal parser exported for tests; public entry points: parse_structure_file/parse_any_structure. Parse phonopy YAML, returns requested cell type (or preferred single structure).
876
854
  export function parse_phonopy_yaml(content, cell_type) {
877
855
  try {
878
856
  // Parse YAML content but exclude large phonon_displacements array for performance
@@ -899,7 +877,7 @@ export function parse_phonopy_yaml(content, cell_type) {
899
877
  const filtered_content = filtered_lines.join(`\n`);
900
878
  const data = yaml_load(filtered_content);
901
879
  if (!data) {
902
- console.error(`Failed to parse phonopy YAML`);
880
+ diag_error(`Failed to parse phonopy YAML`);
903
881
  return null;
904
882
  }
905
883
  // If specific cell type requested, parse only that one
@@ -907,7 +885,7 @@ export function parse_phonopy_yaml(content, cell_type) {
907
885
  const cell = get_phonopy_cell(data, cell_type);
908
886
  if (cell)
909
887
  return convert_phonopy_cell(cell);
910
- console.error(`Requested cell type '${cell_type}' not found in phonopy YAML`);
888
+ diag_error(`Requested cell type '${cell_type}' not found in phonopy YAML`);
911
889
  return null;
912
890
  }
913
891
  // Auto mode: return preferred structure in order of preference
@@ -923,11 +901,11 @@ export function parse_phonopy_yaml(content, cell_type) {
923
901
  get_phonopy_cell(data, `primitive_cell`);
924
902
  if (auto_cell)
925
903
  return convert_phonopy_cell(auto_cell);
926
- console.error(`No valid cells found in phonopy YAML`);
904
+ diag_error(`No valid cells found in phonopy YAML`);
927
905
  return null;
928
906
  }
929
907
  catch (error) {
930
- console.error(`Error parsing phonopy YAML:`, error);
908
+ diag_error(`Error parsing phonopy YAML`, error);
931
909
  return null;
932
910
  }
933
911
  }
@@ -961,8 +939,8 @@ function find_structure_in_json(obj, visited = new WeakSet()) {
961
939
  }
962
940
  return null;
963
941
  }
964
- // Type guard to validate structure-like objects
965
- function is_parsed_structure(obj) {
942
+ // Type guard to validate structure-like objects (sites array with species + coordinates)
943
+ export function is_parsed_structure(obj) {
966
944
  if (!obj || typeof obj !== `object`)
967
945
  return false;
968
946
  const sites = `sites` in obj ? obj.sites : undefined;
@@ -1013,15 +991,12 @@ const detect_json_structure = (content) => {
1013
991
  const structure = find_structure_in_json(parsed);
1014
992
  return structure ? normalize_fractional_coords(structure) : null;
1015
993
  };
1016
- // Auto-detect file format and parse accordingly
1017
- export function parse_structure_file(content, filename) {
994
+ // Internal: auto-detect file format, returns null on failure after recording reasons (see parse error contract at top)
995
+ function parse_structure_file_impl(content, filename) {
1018
996
  // If a filename is provided, try to detect format by file extension first
1019
997
  if (filename) {
1020
998
  // Handle compressed files by removing compression extensions
1021
- let base_filename = filename.toLowerCase();
1022
- while (COMPRESSION_EXTENSIONS_REGEX.test(base_filename)) {
1023
- base_filename = base_filename.replace(COMPRESSION_EXTENSIONS_REGEX, ``);
1024
- }
999
+ const base_filename = strip_compression_extensions(filename);
1025
1000
  const ext = base_filename.split(`.`).pop();
1026
1001
  // Try to detect format by file extension
1027
1002
  if (ext === `xyz` || ext === `extxyz`)
@@ -1035,10 +1010,10 @@ export function parse_structure_file(content, filename) {
1035
1010
  const result = detect_json_structure(content);
1036
1011
  if (result)
1037
1012
  return result;
1038
- console.error(`JSON file does not contain a valid structure format`);
1013
+ diag_error(`JSON file does not contain a valid structure format`);
1039
1014
  }
1040
1015
  catch (error) {
1041
- console.error(`Error parsing JSON file:`, error);
1016
+ diag_error(`Error parsing JSON file`, error);
1042
1017
  }
1043
1018
  return null;
1044
1019
  }
@@ -1053,17 +1028,24 @@ export function parse_structure_file(content, filename) {
1053
1028
  // Try to auto-detect based on content.
1054
1029
  // JSON detection must come before the line-count guard: minified JSON
1055
1030
  // (e.g. fetched via extensionless blob: object URLs) is a single line.
1031
+ const content_start = content.trimStart();
1032
+ const looks_like_json = content_start.startsWith(`{`) || content_start.startsWith(`[`);
1056
1033
  try {
1057
1034
  const result = detect_json_structure(content);
1058
1035
  if (result)
1059
1036
  return result;
1037
+ if (looks_like_json)
1038
+ diag_error(`JSON content does not contain a valid structure format`);
1060
1039
  }
1061
- catch {
1062
- // Not JSON, continue with other format detection
1040
+ catch (error) {
1041
+ // Only swallow silently when content doesn't even look like JSON; otherwise the
1042
+ // syntax error is the most useful failure reason and must be surfaced
1043
+ if (looks_like_json)
1044
+ diag_error(`Invalid JSON`, error);
1063
1045
  }
1064
1046
  const lines = content.trim().split(/\r?\n/);
1065
1047
  if (lines.length < 2) {
1066
- console.error(`File too short to determine format`);
1048
+ diag_error(`File too short to determine format`);
1067
1049
  return null;
1068
1050
  }
1069
1051
  // XYZ format detection: first line should be a number, second line is comment
@@ -1113,11 +1095,20 @@ export function parse_structure_file(content, filename) {
1113
1095
  line.includes(`phonon_supercell:`));
1114
1096
  if (has_phonopy_keywords)
1115
1097
  return parse_phonopy_yaml(content);
1116
- console.error(`Unable to determine file format`);
1098
+ diag_error(`Unable to determine file format`);
1117
1099
  return null;
1118
1100
  }
1119
- // Universal parser that handles JSON and structure files
1101
+ // Auto-detect file format and parse; throws an Error aggregating per-format failure reasons when nothing parses
1102
+ export function parse_structure_file(content, filename) {
1103
+ reset_parse_diagnostics();
1104
+ const structure = parse_structure_file_impl(content, filename);
1105
+ if (structure)
1106
+ return structure;
1107
+ throw aggregate_parse_error(filename);
1108
+ }
1109
+ // Universal parser for JSON and structure files; throws an Error aggregating per-format failure reasons when nothing parses
1120
1110
  export function parse_any_structure(content, filename) {
1111
+ reset_parse_diagnostics();
1121
1112
  const finalize_structure = (structure) => ({
1122
1113
  sites: structure.sites,
1123
1114
  charge: 0,
@@ -1128,23 +1119,21 @@ export function parse_any_structure(content, filename) {
1128
1119
  lattice: { ...structure.lattice, pbc: [true, true, true] },
1129
1120
  }),
1130
1121
  });
1131
- // Try JSON first, but handle nested structures properly
1122
+ // Fast path: content is already a serialized structure object
1132
1123
  try {
1133
1124
  const parsed = JSON.parse(content);
1134
- // Check if it's already a valid structure using proper type guard
1135
1125
  if (is_parsed_structure(parsed)) {
1136
1126
  // Normalize coordinates (wrap fractional to [0,1) and recompute Cartesian)
1137
1127
  return finalize_structure(normalize_fractional_coords(parsed));
1138
1128
  }
1139
- // If not, use parse_structure_file to find nested structures
1140
- const structure = parse_structure_file(content, filename);
1141
- return structure ? finalize_structure(structure) : null;
1142
1129
  }
1143
1130
  catch {
1144
- // Try structure file formats
1145
- const parsed = parse_structure_file(content, filename);
1146
- return parsed ? finalize_structure(parsed) : null;
1131
+ // Not plain JSON — fall through to format detection, which records failure reasons
1147
1132
  }
1133
+ const structure = parse_structure_file_impl(content, filename);
1134
+ if (structure)
1135
+ return finalize_structure(structure);
1136
+ throw aggregate_parse_error(filename);
1148
1137
  }
1149
1138
  // Parse OPTIMADE JSON format
1150
1139
  export function parse_optimade_json(content) {
@@ -1153,16 +1142,80 @@ export function parse_optimade_json(content) {
1153
1142
  return parse_optimade_from_raw(raw);
1154
1143
  }
1155
1144
  catch (error) {
1156
- console.error(`Error parsing OPTIMADE JSON:`, error);
1145
+ diag_error(`Error parsing OPTIMADE JSON`, error);
1157
1146
  return null;
1158
1147
  }
1159
1148
  }
1149
+ // Build sites + lattice shared by parse_optimade_from_raw and optimade_to_crystal.
1150
+ // on_invalid controls whether invalid positions are skipped with a warning or throw;
1151
+ // site_props extracts per-site mass/concentration from the species list.
1152
+ function build_optimade_sites(attrs, opts) {
1153
+ const positions = attrs.cartesian_site_positions ?? [];
1154
+ const species_at_sites = attrs.species_at_sites ?? [];
1155
+ const species_list = Array.isArray(attrs.species) ? attrs.species : undefined;
1156
+ // OPTIMADE stores lattice vectors as rows, so use as-is
1157
+ const lattice_matrix = attrs.lattice_vectors
1158
+ ? matrix3x3_from_rows(attrs.lattice_vectors, `OPTIMADE lattice vector`)
1159
+ : undefined;
1160
+ const lattice_params = lattice_matrix ? math.calc_lattice_params(lattice_matrix) : null;
1161
+ let cart_to_frac = null;
1162
+ if (lattice_matrix && lattice_params) {
1163
+ const converter = cart_to_frac_with_fallback(lattice_matrix, [
1164
+ lattice_params.a,
1165
+ lattice_params.b,
1166
+ lattice_params.c,
1167
+ ]);
1168
+ if (!converter.exact) {
1169
+ diag_warn(`Failed to create exact coordinate converter for OPTIMADE structure`);
1170
+ }
1171
+ cart_to_frac = converter.convert;
1172
+ }
1173
+ const sites = [];
1174
+ for (let idx = 0; idx < positions.length; idx++) {
1175
+ const species_name = species_at_sites[idx];
1176
+ if (!species_name) {
1177
+ if (opts.on_invalid === `throw`)
1178
+ throw new Error(`Missing species for site ${idx}`);
1179
+ diag_warn(`Missing species for site ${idx}, skipping`);
1180
+ continue;
1181
+ }
1182
+ let xyz;
1183
+ try {
1184
+ xyz = vec3_from_values(positions[idx], `OPTIMADE atom position ${idx + 1}`);
1185
+ }
1186
+ catch (error) {
1187
+ if (opts.on_invalid === `throw`)
1188
+ throw error;
1189
+ diag_warn(`Invalid position data at site ${idx}: ${error}`);
1190
+ continue;
1191
+ }
1192
+ const { symbol: element, sym_idx } = resolve_optimade_element(species_name, species_list, idx);
1193
+ // Calculate fractional coordinates if lattice is available
1194
+ const abc = cart_to_frac ? cart_to_frac(xyz) : [0, 0, 0];
1195
+ const site_props = {};
1196
+ if (opts.site_props) {
1197
+ // Extract mass/concentration for the chosen element. sym_idx indexes the (parallel)
1198
+ // chemical_symbols/mass/concentration arrays; -1 (name resolved directly, no
1199
+ // chemical_symbols) falls back to index 0 — the single-element entry.
1200
+ const spec = species_list?.find((entry) => entry.name === species_name);
1201
+ const spec_idx = Math.max(sym_idx, 0);
1202
+ if (spec?.mass?.[spec_idx] !== undefined)
1203
+ site_props.mass = spec.mass[spec_idx];
1204
+ if (spec?.concentration?.[spec_idx] !== undefined &&
1205
+ spec.concentration[spec_idx] !== 1) {
1206
+ site_props.concentration = spec.concentration[spec_idx];
1207
+ }
1208
+ }
1209
+ sites.push(make_site(element, abc, xyz, `${element}${idx + 1}`, site_props));
1210
+ }
1211
+ return { sites, lattice_matrix, lattice_params };
1212
+ }
1160
1213
  // Parse OPTIMADE from already-parsed JSON
1161
1214
  export function parse_optimade_from_raw(raw) {
1162
1215
  try {
1163
1216
  const structure = extract_optimade_structure_from_raw(raw);
1164
1217
  if (!structure) {
1165
- console.error(`No valid OPTIMADE structure found in JSON`);
1218
+ diag_error(`No valid OPTIMADE structure found in JSON`);
1166
1219
  return null;
1167
1220
  }
1168
1221
  const attrs = structure.attributes;
@@ -1170,83 +1223,28 @@ export function parse_optimade_from_raw(raw) {
1170
1223
  const positions_raw = attrs.cartesian_site_positions;
1171
1224
  const species_raw = attrs.species_at_sites;
1172
1225
  if (!(Array.isArray(positions_raw) && Array.isArray(species_raw))) {
1173
- console.error(`OPTIMADE JSON missing required position or species data`);
1226
+ diag_error(`OPTIMADE JSON missing required position or species data`);
1174
1227
  return null;
1175
1228
  }
1176
1229
  if (positions_raw.length !== species_raw.length) {
1177
- console.error(`OPTIMADE JSON position/species count mismatch`);
1230
+ diag_error(`OPTIMADE JSON position/species count mismatch`);
1178
1231
  return null;
1179
1232
  }
1180
- const positions = positions_raw;
1181
- const species = species_raw;
1182
- // Optimade stores lattice vectors as rows, so use as is
1183
- const lattice_matrix = attrs.lattice_vectors
1184
- ? [
1185
- vec3_from_values(attrs.lattice_vectors[0], `OPTIMADE lattice vector 1`),
1186
- vec3_from_values(attrs.lattice_vectors[1], `OPTIMADE lattice vector 2`),
1187
- vec3_from_values(attrs.lattice_vectors[2], `OPTIMADE lattice vector 3`),
1188
- ]
1189
- : undefined;
1190
- const optimade_lattice_params = lattice_matrix
1191
- ? math.calc_lattice_params(lattice_matrix)
1192
- : null;
1193
- // Parse atomic sites
1194
- const optimade_exact_cart_to_frac = lattice_matrix
1195
- ? try_create_cart_to_frac(lattice_matrix)
1196
- : null;
1197
- const optimade_cart_to_frac = lattice_matrix && optimade_lattice_params
1198
- ? (optimade_exact_cart_to_frac ??
1199
- ((xyz) => approximate_cart_to_frac(xyz, [
1200
- optimade_lattice_params.a,
1201
- optimade_lattice_params.b,
1202
- optimade_lattice_params.c,
1203
- ])))
1204
- : null;
1205
- if (lattice_matrix && !optimade_exact_cart_to_frac) {
1206
- console.warn(`Failed to create exact coordinate converter for OPTIMADE structure`);
1207
- }
1208
- const optimade_species = Array.isArray(attrs.species) ? attrs.species : undefined;
1209
- const sites = [];
1210
- for (let idx = 0; idx < positions.length; idx++) {
1211
- const pos = positions[idx];
1212
- const element_symbol = species[idx];
1213
- let xyz;
1214
- try {
1215
- xyz = vec3_from_values(pos, `OPTIMADE site ${idx} position`);
1216
- }
1217
- catch (error) {
1218
- console.warn(`Invalid position data at site ${idx}: ${error}`);
1219
- continue;
1220
- }
1221
- const { symbol: element } = resolve_optimade_element(element_symbol, optimade_species, idx);
1222
- // Calculate fractional coordinates if lattice is available
1223
- const abc = optimade_cart_to_frac ? optimade_cart_to_frac(xyz) : [0, 0, 0];
1224
- const site = {
1225
- species: [{ element, occu: 1, oxidation_state: 0 }],
1226
- abc,
1227
- xyz,
1228
- label: `${element}${idx + 1}`,
1229
- properties: {},
1230
- };
1231
- sites.push(site);
1232
- }
1233
+ const { sites, lattice_matrix, lattice_params } = build_optimade_sites(attrs, {
1234
+ on_invalid: `skip`,
1235
+ });
1233
1236
  if (sites.length === 0) {
1234
- console.error(`No valid sites found in OPTIMADE JSON`);
1237
+ diag_error(`No valid sites found in OPTIMADE JSON`);
1235
1238
  return null;
1236
1239
  }
1237
- // Create structure object
1238
- let lattice;
1239
- if (lattice_matrix && optimade_lattice_params) {
1240
- lattice = { matrix: lattice_matrix, ...optimade_lattice_params };
1241
- }
1242
- const structure_result = {
1240
+ return {
1243
1241
  sites,
1244
- ...(lattice && { lattice }),
1242
+ ...(lattice_matrix &&
1243
+ lattice_params && { lattice: { matrix: lattice_matrix, ...lattice_params } }),
1245
1244
  };
1246
- return structure_result;
1247
1245
  }
1248
1246
  catch (error) {
1249
- console.error(`Error parsing OPTIMADE JSON:`, error);
1247
+ diag_error(`Error parsing OPTIMADE JSON`, error);
1250
1248
  return null;
1251
1249
  }
1252
1250
  }
@@ -1268,9 +1266,7 @@ function extract_optimade_structure_from_raw(raw) {
1268
1266
  const candidate = Array.isArray(payload) ? payload[0] : payload;
1269
1267
  return is_optimade_structure_object(candidate) ? candidate : null;
1270
1268
  }
1271
- const unwrap_data = (value) => value && typeof value === `object` && `data` in value
1272
- ? value.data
1273
- : value;
1269
+ const unwrap_data = (value) => value && typeof value === `object` && `data` in value ? value.data : value;
1274
1270
  // Type guard: verify minimal OPTIMADE structure shape
1275
1271
  function is_optimade_structure_object(value) {
1276
1272
  if (!value || typeof value !== `object`)
@@ -1286,47 +1282,18 @@ function is_optimade_structure_object(value) {
1286
1282
  }
1287
1283
  // Convert OPTIMADE structure to Crystal format
1288
1284
  export function optimade_to_crystal(optimade_structure) {
1289
- const { lattice_vectors, cartesian_site_positions, species_at_sites, species, ...properties } = optimade_structure.attributes;
1285
+ const { lattice_vectors, cartesian_site_positions, species_at_sites, species: _species, // excluded from the properties rest
1286
+ ...properties } = optimade_structure.attributes;
1290
1287
  if (!lattice_vectors || !cartesian_site_positions || !species_at_sites) {
1291
- console.error(`Missing required OPTIMADE structure data`);
1288
+ diag_error(`Missing required OPTIMADE structure data`);
1292
1289
  return null;
1293
1290
  }
1294
1291
  try {
1295
- const lattice_matrix = [
1296
- vec3_from_values(lattice_vectors[0], `OPTIMADE lattice vector 1`),
1297
- vec3_from_values(lattice_vectors[1], `OPTIMADE lattice vector 2`),
1298
- vec3_from_values(lattice_vectors[2], `OPTIMADE lattice vector 3`),
1299
- ];
1300
- const lattice_params = math.calc_lattice_params(lattice_matrix);
1301
- const crystal_cart_to_frac = try_create_cart_to_frac(lattice_matrix) ??
1302
- ((xyz) => approximate_cart_to_frac(xyz, [lattice_params.a, lattice_params.b, lattice_params.c]));
1303
- const sites = cartesian_site_positions.map((pos, idx) => {
1304
- const element_symbol = species_at_sites[idx];
1305
- if (!element_symbol)
1306
- throw new Error(`Missing species for site ${idx}`);
1307
- const { symbol: element, sym_idx } = resolve_optimade_element(element_symbol, species, idx);
1308
- const xyz = vec3_from_values(pos, `OPTIMADE atom position ${idx + 1}`);
1309
- const abc = crystal_cart_to_frac ? crystal_cart_to_frac(xyz) : [0, 0, 0];
1310
- // Extract mass/concentration for the chosen element. sym_idx indexes the (parallel)
1311
- // chemical_symbols/mass/concentration arrays; -1 (name resolved directly, no
1312
- // chemical_symbols) falls back to index 0 — the single-element entry.
1313
- const spec = species?.find((entry) => entry.name === element_symbol);
1314
- const spec_idx = Math.max(sym_idx, 0);
1315
- const site_props = {};
1316
- if (spec?.mass?.[spec_idx] !== undefined)
1317
- site_props.mass = spec.mass[spec_idx];
1318
- if (spec?.concentration?.[spec_idx] !== undefined &&
1319
- spec.concentration[spec_idx] !== 1) {
1320
- site_props.concentration = spec.concentration[spec_idx];
1321
- }
1322
- return {
1323
- species: [{ element, occu: 1, oxidation_state: 0 }],
1324
- abc,
1325
- xyz,
1326
- label: `${element}${idx + 1}`,
1327
- properties: site_props,
1328
- };
1329
- });
1292
+ const { sites, lattice_matrix, lattice_params } = build_optimade_sites(optimade_structure.attributes, { on_invalid: `throw`, site_props: true });
1293
+ if (!lattice_matrix || !lattice_params) {
1294
+ diag_error(`Missing required OPTIMADE structure data`);
1295
+ return null;
1296
+ }
1330
1297
  return {
1331
1298
  sites,
1332
1299
  lattice: { matrix: lattice_matrix, ...lattice_params, pbc: [true, true, true] },
@@ -1335,7 +1302,7 @@ export function optimade_to_crystal(optimade_structure) {
1335
1302
  };
1336
1303
  }
1337
1304
  catch (err) {
1338
- console.error(`Error converting OPTIMADE to Crystal format:`, err);
1305
+ diag_error(`Error converting OPTIMADE to Crystal format`, err);
1339
1306
  return null;
1340
1307
  }
1341
1308
  }
@@ -1369,12 +1336,8 @@ export function is_structure_file(filename) {
1369
1336
  return false;
1370
1337
  }
1371
1338
  export const detect_structure_type = (filename, content) => {
1372
- const lower_filename = filename.toLowerCase();
1373
1339
  // Normalize compressed suffixes (gz, gzip, zip, xz, bz2) for detection parity
1374
- let name_to_check = lower_filename;
1375
- while (COMPRESSION_EXTENSIONS_REGEX.test(name_to_check)) {
1376
- name_to_check = name_to_check.replace(COMPRESSION_EXTENSIONS_REGEX, ``);
1377
- }
1340
+ const name_to_check = strip_compression_extensions(filename);
1378
1341
  if (name_to_check.endsWith(`.json`)) {
1379
1342
  try {
1380
1343
  const parsed = JSON.parse(content);