SincCity 0.2.0__tar.gz

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.
sinccity-0.2.0/LICENSE ADDED
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, EasyScience
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,298 @@
1
+ Metadata-Version: 2.4
2
+ Name: SincCity
3
+ Version: 0.2.0
4
+ Summary: Generic Scattering Calculator: I(q) of a real-space structure (PDB, GRO, SLD or VTK)
5
+ Author: SincCity contributors
6
+ License-Expression: BSD-3-Clause
7
+ Project-URL: Documentation, https://easyscience.github.io/SincCity/
8
+ Project-URL: Source, https://github.com/easyscience/SincCity
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: numpy>=2.0
13
+ Requires-Dist: periodictable>=1.6
14
+ Provides-Extra: plot
15
+ Requires-Dist: plotly>=5.0; extra == "plot"
16
+ Provides-Extra: sasdata
17
+ Requires-Dist: sasdata>=0.8; extra == "sasdata"
18
+ Provides-Extra: docs
19
+ Requires-Dist: mkdocs<2,>=1.6; extra == "docs"
20
+ Requires-Dist: mkdocs-material>=9.6; extra == "docs"
21
+ Requires-Dist: mkdocstrings[python]>=1.0; extra == "docs"
22
+ Provides-Extra: dev
23
+ Requires-Dist: sinccity[plot,sasdata]; extra == "dev"
24
+ Requires-Dist: pytest>=7.0; extra == "dev"
25
+ Requires-Dist: ruff>=0.14; extra == "dev"
26
+ Dynamic: license-file
27
+
28
+ # SincCity: Generic Scattering Calculator
29
+
30
+ The orientationally averaged I(q) of a real-space structure, by the Debye
31
+ formula: atoms from a **PDB** or GROMACS **GRO** file, pixels from a
32
+ SasView **SLD** file, or the cells of a legacy ASCII **VTK** mesh. It is the
33
+ nuclear, 1D part of SasView's *Generic Scattering Calculator*, as a plain
34
+ Python package for scripts and notebooks. There are no 2D, magnetic or
35
+ polarisation options.
36
+
37
+ The name comes from the sinc function, sinc x = sin x / x, at the heart of
38
+ the Debye formula: every pair of points a distance r apart adds its weight
39
+ times sinc(q r) to the intensity.
40
+
41
+ `sinccity` is developed alongside [IQuana](https://github.com/ai4se1dk/IQuana)
42
+ but does not depend on it. The common interface is `profile.to_data1d()`, which
43
+ returns a sasdata `Data1D` that `IQuana.set_data` can ingest.
44
+
45
+ ## Installation
46
+
47
+ ```bash
48
+ pip install -e . # numpy and periodictable only
49
+ pip install -e ".[plot,sasdata]" # plus plotly (plots) and sasdata (Data1D output)
50
+ ```
51
+
52
+ The required dependencies are `numpy` and `periodictable` (scattering lengths
53
+ and densities of atoms). Two extras are optional: `plot` installs `plotly`,
54
+ which `plot_points` and `plot_profile` need, and `sasdata` installs `sasdata`,
55
+ which `to_data1d` needs. Without them those calls raise an `ImportError` that
56
+ names the extra.
57
+
58
+ ## Usage
59
+
60
+ ```python
61
+ from sinccity import GenericScatteringCalculator
62
+
63
+ gsc = GenericScatteringCalculator()
64
+ gsc.load_structure('particle.sld') # .pdb, .gro, .sld or .vtk
65
+ gsc.plot_points() # SasView's "Draw Points", in 3D
66
+ gsc.set_q_grid(3e-4, 0.3, 30) # optional: SasView's defaults
67
+ gsc.set_params(sld_solvent=6.34) # optional: 10^-6 Å^-2, D2O here
68
+ profile = gsc.calculate()
69
+ profile.describe()
70
+ gsc.plot_profile() # or gsc.plot_profile(data=some_data1d)
71
+ data = profile.to_data1d() # a sasdata Data1D, ready for a fit
72
+ ```
73
+
74
+ An aggregate from a molecular-dynamics run, in several contrasts:
75
+
76
+ ```python
77
+ gsc.load_structure(
78
+ 'CTA178-eq5.gro',
79
+ residues=['CTA'], # the micelle, not its solvent
80
+ masses={'CTA': cta_masses}, # from the topology, in atom order
81
+ )
82
+ gsc.set_params(scale=0.01, size_pd=0.1)
83
+ profiles = gsc.calculate_series([
84
+ {'label': 'h-CTA in D2O', 'params': {'sld_solvent': 6.36}},
85
+ {'label': 'h-CTA in H2O', 'params': {'sld_solvent': -0.56}},
86
+ {'label': 'd-CTA in H2O', 'params': {'sld_solvent': -0.56, 'deuterated': ['CTA']}},
87
+ ])
88
+ ```
89
+
90
+ The calculation is the Debye sum over all points:
91
+
92
+ ```text
93
+ w_j = (sld_j - sld_solvent * 1e-6) * volume_j SLD in Å^-2, volume in Å^3
94
+ D(q) = Σ_j Σ_k w_j w_k sin(q r_jk) / (q r_jk)
95
+ I(q) = scale * 1e8 * D(q) / V_norm + background 1/cm
96
+ ```
97
+
98
+ `sld_solvent` is in 10⁻⁶ Å⁻², like every sasmodels SLD parameter. SasView's
99
+ calculator takes Å⁻² instead; a value that looks like Å⁻² triggers a warning.
100
+ The structure's own SLDs stay in Å⁻², as the files store them.
101
+
102
+ **Normalisation.** `V_norm` is the volume of the points that scatter, those
103
+ whose SLD differs from the solvent's. For a particle of pixels or cells in a
104
+ box of solvent, the curve at `scale=1` then equals sasmodels' form factor at
105
+ `scale=1`, so `scale` is a volume fraction. When the solvent exactly matches a
106
+ population of points, those points stop counting and the curve jumps by the
107
+ volume ratio; for contrast-variation series set
108
+ `set_params(normalisation_volume=...)` explicitly (`'auto'` restores the
109
+ default). SasView calls the override `total_volume` and, when some points
110
+ have zero contrast, also multiplies by the ratio of all points' volume to the
111
+ scattering volume. This calculator does not.
112
+
113
+ **Size polydispersity.** `set_params(size_pd=0.1)` sums blown-up and shrunk
114
+ copies of the whole structure, as sasmodels does for `radius_pd`: `size_pd_n`
115
+ size factors (35) evenly over ±`size_pd_nsigma` (3) standard deviations,
116
+ factors at or below 0 dropped, each weighted by the Gaussian's height there.
117
+ A factor f stretches every distance by f and every amplitude by f³, so
118
+
119
+ ```text
120
+ I(q) = scale * 1e8 * Σ n_f f⁶ D(f q) / V_norm + background
121
+ V_norm = Σ n_f f³ V V: the volume of the points that scatter
122
+ ```
123
+
124
+ with the shares n_f adding up to 1, so `scale` stays a volume fraction. The
125
+ lattice, histogram and average kernels evaluate their one distance spectrum
126
+ at f·q for every size, which costs only the sums over q; `'pairs'` repeats
127
+ the whole pair sum per size and warns. The automatic histogram bin and the
128
+ cell-size warning use the largest factor, `1 + size_pd_nsigma * size_pd`.
129
+
130
+ **Deuteration.** `set_params(deuterated=['CTA'])` gives the hydrogens of
131
+ these residue names the SLD of D at `calculate()`; volumes are unchanged,
132
+ since D and H have the same natural atomic volume. It needs a structure with
133
+ residue names (PDB, GRO or `Structure.from_atoms` with `resnames=`); a name
134
+ that is not in the structure, or whose atoms include no hydrogen, is an error.
135
+ `structure.deuterated(['CTA'])` returns the same change as a new structure.
136
+
137
+ ## File formats
138
+
139
+ Positions are in Å and SLDs in Å⁻² in all four (a `.gro` file's nm are converted).
140
+
141
+ | Format | What is read |
142
+ |---|---|
143
+ | PDB | `ATOM` records of the first model. `include_hetatm=True` adds `HETATM` records, never waters (`HOH`, `DOD`, `WAT` and the MD names `SOL`, `TIP3`, `TIP4`, `SPC`, `SPCE`, `OPC`). One alternate-location ID for the whole structure, occupancy not applied. Residue names from columns 18–21, where MD programs write four-letter names. Elements as below. Each element gets SasView's SLD and *natural atomic volume* from periodictable; an unknown element is an error. `CONECT` bonds are drawn. A `CRYST1` box is stored unless it is unreadable or a placeholder (a side of 1 Å or less). Residue numbers that are not decimal integers (hybrid-36) are renumbered in order. |
144
+ | GRO | All atoms, cut at GROMACS's fixed columns (names may touch numbers; `-ndec` files are read). Velocities are ignored; the box comes from the last line and is used to unwrap the aggregate. Elements as below. |
145
+ | SLD | One header line, then `x y z sld`, `x y z sld mx my mz` or `x y z sld mx my mz volume`. Magnetic columns are ignored; six-column magnetic-only files are refused. Without a volume column the points must form a lattice and each pixel's volume is the product of the steps. A sparse subset that skips planes looks like a coarser lattice, so give such files the volume column. |
146
+ | VTK | Legacy ASCII `UNSTRUCTURED_GRID` (classic or 5.1 layout) with tetrahedra, voxels, hexahedra, wedges and pyramids. The nuclear SLD is the one single-component `SCALARS` field, per cell or per point (averaged to cells as SasView does). Each cell becomes one point at its volume centroid. Binary files are refused with an export recipe. |
147
+
148
+ **Selecting residues.** `load_structure(path, residues=['CTA'])` reads only
149
+ the atoms of these residue names, from PDB and GRO files. When given, it is
150
+ the whole selection: the PDB water and `HETATM` rules do not apply. A `.gro`
151
+ file of a solvated system needs it; without it the reader notes how many
152
+ residue names it read. Options a format does not have are ignored with a
153
+ note. The structure keeps the options it was read with
154
+ (`structure.reader_options`), so `get_settings()` records them however the
155
+ structure was installed.
156
+
157
+ **Elements.** For PDB and GRO files each residue name gets its elements from
158
+ the first source that has them:
159
+
160
+ 1. `masses={'CTA': [14.007, 12.011, ...]}`: the atom masses in u in topology
161
+ order. Each mass gives the element of nearest mass, H to U and D, within
162
+ 0.01 u. Hydrogen mass repartitioning (H at 3.024 u) or united atoms (CH₂
163
+ at 14.027 u) are no element's and an error: pass `elements=` for them.
164
+ 2. `elements={'CTA': ['N', 'C', ...]}`: the element symbols in order.
165
+ 3. The PDB element column, when it is filled for every atom of the residue.
166
+ 4. A guess from the atom names, with a note. PDB files use SasView's column
167
+ rule (` CA ` is carbon, `CA` starting in column 13 calcium). GRO files have
168
+ no column convention: leading digits are dropped, then the first letter is
169
+ the element, except that a one-atom residue whose name is a two-letter
170
+ element (`NA`, `CL`) is that element. So `CA` in a protein is carbon and a
171
+ `CA` ion calcium. The note lists every two-letter guess, since those are
172
+ the likeliest to be wrong.
173
+
174
+ Masses and element lists are matched by the atom's position in its residue
175
+ (a run of atoms with one residue number and name), so the atom names of the structure
176
+ and the topology may differ. `structure.element_sources` records the source
177
+ per residue name. `structure.with_symbols(symbols)` returns a copy with
178
+ corrected elements, and `Structure.from_atoms(xyz, symbols, resids=,
179
+ resnames=, names=)` builds atoms that were read elsewhere.
180
+
181
+ **Periodic boxes.** GROMACS writes every position back into the box, which
182
+ can split molecules and the aggregate. With `unwrap='auto'` (the default) a
183
+ `.gro` file is unwrapped: each molecule is made whole around its first atom,
184
+ then moved by whole box lengths next to the aggregate's centre, the circular
185
+ mean of the molecule centres. A note counts the atoms moved. It works for one
186
+ aggregate smaller than half the box. A `.gro` file whose box is zero (no
187
+ periodic box) is read as it is, with a note. PDB files are unwrapped only with
188
+ `unwrap=True`, because many programs write a placeholder `CRYST1`; a missing
189
+ or placeholder box is then an error. Triclinic boxes cannot be unwrapped
190
+ (`unwrap=False` reads such files without the box).
191
+
192
+ The readers follow SasView's (`sas.sascalc.calculator.sas_gen`, BSD-3-Clause,
193
+ Copyright (c) 2009-2026, SasView Developers) with one deviation: the VTK cell
194
+ geometry is recomputed, because SasView's volumes for hexahedra, wedges and
195
+ pyramids are wrong. The GRO reader and the residue options come from
196
+ coords2sans.
197
+
198
+ ## Things to know
199
+
200
+ - **PDB structures in a solvent are only qualitative.** The natural atomic
201
+ volumes use bulk elemental densities, liquid H₂, N₂ and O₂ for H, N and O.
202
+ A typical residue gets about 310 ų instead of about 133 ų, so in D₂O a
203
+ protein comes out about 8× too intense at q → 0. There is no hydration
204
+ shell and no H/D exchange either. `calculate()` warns with the actual
205
+ volume ratio. In vacuum (`sld_solvent=0`) the result is right. For atoms,
206
+ `scale` is not a volume fraction.
207
+ - **Points stand in for cells.** Pixels and VTK cells are treated as points,
208
+ which degrades as q × cell size approaches 1. `calculate()` warns above
209
+ that, with the cell size taken as the lattice step or the cell diameter
210
+ divided by √3.
211
+ - **Kernels.** `set_params(kernel=...)` picks how D(q) is summed; the
212
+ profile's `method` records it.
213
+
214
+ | `kernel` | How | Exact? | Time |
215
+ |---|---|---|---|
216
+ | `'lattice'` | FFT autocorrelation of the pixel grid | yes | well under a second for 10⁵ pixels |
217
+ | `'histogram'` | pairs counted by distance, then one sum per q over the occupied distances | to about 1e-4 | about 2 s for 11 000 atoms, N² |
218
+ | `'average'` | ⟨A⟩²: each point's distance from the bounding-box centre | only for spherically symmetric structures | linear in N |
219
+ | `'pairs'` | every pair at every q | to the last digit | 2–3 s for 4 000 points, 25 s for 8 000 |
220
+
221
+ `'auto'` (the default) is `'lattice'` for pixels on a lattice and
222
+ `'histogram'` for everything else. The histogram bin is `'auto'`:
223
+ `min(0.01 Å, 1e-3 / (q_max · f_max))`, with f_max the largest size factor
224
+ (1 without polydispersity), which keeps the rounding phase error below
225
+ 5e-4 rad; `histogram_bin=` sets it, and `profile.histogram_bin` records it.
226
+ A cached spectrum with a finer bin is reused.
227
+ `'average'` reproduces SasView's `is_avg=True` and suits round aggregates
228
+ above about 10⁵ atoms. `calculate(progress=...)` can report progress or
229
+ stop the pair passes of `'histogram'` and `'pairs'`.
230
+ - **Contrast series are cheap.** The lattice, histogram and average kernels
231
+ keep the scattering lengths and the volumes apart in their distance
232
+ spectrum, so the calculator keeps the last one and a new `sld_solvent`,
233
+ `scale`, `background` or `normalisation_volume` costs only the sums over
234
+ q. A new structure, deuteration, kernel, a finer bin or a change of which
235
+ points match the solvent repeats the pair pass.
236
+ - **The curve is theory.** `profile.to_data1d()` returns a `Data1D` with no
237
+ dI and no dQ, with `qmin`, `qmax` and an empty `mask` set. A fit of it
238
+ should state its weighting and resolution explicitly; pass
239
+ `to_data1d(dI=...)` to attach uncertainties.
240
+ - **Reproducibility.** `gsc.get_settings()` records the file path and
241
+ SHA-256, the reader options, the q grid, every parameter and the package
242
+ versions. `GenericScatteringCalculator.from_settings(settings)` rebuilds
243
+ the calculator and warns if the file or the periodictable version changed;
244
+ records of version 0.1 replay with the new parameters at their defaults,
245
+ except the kernel: `'auto'` for pixels on a lattice and otherwise
246
+ `'pairs'`, the pair sum 0.1 used, so a record gives the curve it was
247
+ made with.
248
+ A structure no file and options can rebuild (`from_arrays`, `from_atoms`,
249
+ or a `deuterated` / `with_symbols` copy) is recorded as `'in-memory'`, which
250
+ `from_settings` refuses; a derived copy also records `derived_from`.
251
+ - **Series and worker processes.** `gsc.calculate_series(series)` computes
252
+ one profile per entry, each a `label` and `set_params` keys that apply to
253
+ that entry only. All entries are checked before anything runs, entries
254
+ that share a deuteration state and kernel share one pair pass, and the
255
+ profiles come back in the order given, each with its `label`. The
256
+ parameters are left as they were. A settings record plus a series is plain
257
+ JSON, so another process can run it:
258
+ `GenericScatteringCalculator.from_settings(settings).calculate_series(series)`.
259
+ Call `sinccity.set_verbosity('quiet')` there; the logger is per process.
260
+ - **Console output.** Progress lines go through the `sinccity` logger;
261
+ `sinccity.set_verbosity('quiet')` silences them.
262
+
263
+ ## Coming from coords2sans
264
+
265
+ `sinccity` takes over coords2sans's features; its names and units differ in
266
+ places.
267
+
268
+ | coords2sans | sinccity |
269
+ |---|---|
270
+ | `Structure.read(path, residues, masses, elements)` | `sinccity.load(path, residues=, masses=, elements=)` or `gsc.load_structure(...)` |
271
+ | `Structure(resids, resnames, names, positions, elements)` | `Structure.from_atoms(xyz, symbols, resids=, resnames=, names=)` |
272
+ | `structure.positions`, `structure.elements` (editable) | `structure.xyz`, `structure.symbols`; `structure.with_symbols(new)` |
273
+ | `structure.sld(deuterated=[...])` | `structure.deuterated([...]).sld`, or `set_params(deuterated=[...])` |
274
+ | `SansCalculator(solvent_sld=6.36e-6)` | `set_params(sld_solvent=6.36)`, in 10⁻⁶ Å⁻² |
275
+ | `q_min`, `q_max`, `q_points`, `q_log` | `set_q_grid(qmin, qmax, npoints, spacing='log' or 'linear')`; the default grid is SasView's, 3e-4 to 0.3 Å⁻¹ in 30 points |
276
+ | `polydispersity`, `size_points`, `SIZE_RANGE` | `size_pd`, `size_pd_n`, `size_pd_nsigma` |
277
+ | `average_amplitude=True` | `kernel='average'` |
278
+ | `BIN_WIDTH` (0.01 Å) | `histogram_bin`, by default at most 0.01 Å and smaller at high q or with a size spread |
279
+ | `calculate(structure, label)` → `Curve` | `set_structure(structure)`, then `calculate(label=)` → `ScatteringProfile` |
280
+ | `Curve.save`, `Curve.plot(ax)` | `profile.save_csv(path)` (the same two columns); `ax.plot(profile.q, profile.intensity)` or `profile.plot()` (plotly) |
281
+ | `SansTask` | `get_settings()` plus a series, run by `from_settings(settings).calculate_series(series)` |
282
+
283
+ Two rules differ on purpose. PDB files without an element column use
284
+ SasView's name rule (` CA ` is carbon), and in `.gro` files a two-letter
285
+ element name counts only in a one-atom residue (so `CA` in a protein is
286
+ carbon). V_norm is the volume of the atoms that scatter, which equals
287
+ coords2sans's volume of all atoms unless an atom matches the solvent.
288
+
289
+ ## Tests
290
+
291
+ ```bash
292
+ pytest tests
293
+ ```
294
+
295
+ ## Licence
296
+
297
+ BSD 3-Clause, like IQuana. The test structures in `tests/data/` are
298
+ SasView test data, BSD 3-Clause, Copyright (c) 2009-2026, SasView Developers.
@@ -0,0 +1,271 @@
1
+ # SincCity: Generic Scattering Calculator
2
+
3
+ The orientationally averaged I(q) of a real-space structure, by the Debye
4
+ formula: atoms from a **PDB** or GROMACS **GRO** file, pixels from a
5
+ SasView **SLD** file, or the cells of a legacy ASCII **VTK** mesh. It is the
6
+ nuclear, 1D part of SasView's *Generic Scattering Calculator*, as a plain
7
+ Python package for scripts and notebooks. There are no 2D, magnetic or
8
+ polarisation options.
9
+
10
+ The name comes from the sinc function, sinc x = sin x / x, at the heart of
11
+ the Debye formula: every pair of points a distance r apart adds its weight
12
+ times sinc(q r) to the intensity.
13
+
14
+ `sinccity` is developed alongside [IQuana](https://github.com/ai4se1dk/IQuana)
15
+ but does not depend on it. The common interface is `profile.to_data1d()`, which
16
+ returns a sasdata `Data1D` that `IQuana.set_data` can ingest.
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ pip install -e . # numpy and periodictable only
22
+ pip install -e ".[plot,sasdata]" # plus plotly (plots) and sasdata (Data1D output)
23
+ ```
24
+
25
+ The required dependencies are `numpy` and `periodictable` (scattering lengths
26
+ and densities of atoms). Two extras are optional: `plot` installs `plotly`,
27
+ which `plot_points` and `plot_profile` need, and `sasdata` installs `sasdata`,
28
+ which `to_data1d` needs. Without them those calls raise an `ImportError` that
29
+ names the extra.
30
+
31
+ ## Usage
32
+
33
+ ```python
34
+ from sinccity import GenericScatteringCalculator
35
+
36
+ gsc = GenericScatteringCalculator()
37
+ gsc.load_structure('particle.sld') # .pdb, .gro, .sld or .vtk
38
+ gsc.plot_points() # SasView's "Draw Points", in 3D
39
+ gsc.set_q_grid(3e-4, 0.3, 30) # optional: SasView's defaults
40
+ gsc.set_params(sld_solvent=6.34) # optional: 10^-6 Å^-2, D2O here
41
+ profile = gsc.calculate()
42
+ profile.describe()
43
+ gsc.plot_profile() # or gsc.plot_profile(data=some_data1d)
44
+ data = profile.to_data1d() # a sasdata Data1D, ready for a fit
45
+ ```
46
+
47
+ An aggregate from a molecular-dynamics run, in several contrasts:
48
+
49
+ ```python
50
+ gsc.load_structure(
51
+ 'CTA178-eq5.gro',
52
+ residues=['CTA'], # the micelle, not its solvent
53
+ masses={'CTA': cta_masses}, # from the topology, in atom order
54
+ )
55
+ gsc.set_params(scale=0.01, size_pd=0.1)
56
+ profiles = gsc.calculate_series([
57
+ {'label': 'h-CTA in D2O', 'params': {'sld_solvent': 6.36}},
58
+ {'label': 'h-CTA in H2O', 'params': {'sld_solvent': -0.56}},
59
+ {'label': 'd-CTA in H2O', 'params': {'sld_solvent': -0.56, 'deuterated': ['CTA']}},
60
+ ])
61
+ ```
62
+
63
+ The calculation is the Debye sum over all points:
64
+
65
+ ```text
66
+ w_j = (sld_j - sld_solvent * 1e-6) * volume_j SLD in Å^-2, volume in Å^3
67
+ D(q) = Σ_j Σ_k w_j w_k sin(q r_jk) / (q r_jk)
68
+ I(q) = scale * 1e8 * D(q) / V_norm + background 1/cm
69
+ ```
70
+
71
+ `sld_solvent` is in 10⁻⁶ Å⁻², like every sasmodels SLD parameter. SasView's
72
+ calculator takes Å⁻² instead; a value that looks like Å⁻² triggers a warning.
73
+ The structure's own SLDs stay in Å⁻², as the files store them.
74
+
75
+ **Normalisation.** `V_norm` is the volume of the points that scatter, those
76
+ whose SLD differs from the solvent's. For a particle of pixels or cells in a
77
+ box of solvent, the curve at `scale=1` then equals sasmodels' form factor at
78
+ `scale=1`, so `scale` is a volume fraction. When the solvent exactly matches a
79
+ population of points, those points stop counting and the curve jumps by the
80
+ volume ratio; for contrast-variation series set
81
+ `set_params(normalisation_volume=...)` explicitly (`'auto'` restores the
82
+ default). SasView calls the override `total_volume` and, when some points
83
+ have zero contrast, also multiplies by the ratio of all points' volume to the
84
+ scattering volume. This calculator does not.
85
+
86
+ **Size polydispersity.** `set_params(size_pd=0.1)` sums blown-up and shrunk
87
+ copies of the whole structure, as sasmodels does for `radius_pd`: `size_pd_n`
88
+ size factors (35) evenly over ±`size_pd_nsigma` (3) standard deviations,
89
+ factors at or below 0 dropped, each weighted by the Gaussian's height there.
90
+ A factor f stretches every distance by f and every amplitude by f³, so
91
+
92
+ ```text
93
+ I(q) = scale * 1e8 * Σ n_f f⁶ D(f q) / V_norm + background
94
+ V_norm = Σ n_f f³ V V: the volume of the points that scatter
95
+ ```
96
+
97
+ with the shares n_f adding up to 1, so `scale` stays a volume fraction. The
98
+ lattice, histogram and average kernels evaluate their one distance spectrum
99
+ at f·q for every size, which costs only the sums over q; `'pairs'` repeats
100
+ the whole pair sum per size and warns. The automatic histogram bin and the
101
+ cell-size warning use the largest factor, `1 + size_pd_nsigma * size_pd`.
102
+
103
+ **Deuteration.** `set_params(deuterated=['CTA'])` gives the hydrogens of
104
+ these residue names the SLD of D at `calculate()`; volumes are unchanged,
105
+ since D and H have the same natural atomic volume. It needs a structure with
106
+ residue names (PDB, GRO or `Structure.from_atoms` with `resnames=`); a name
107
+ that is not in the structure, or whose atoms include no hydrogen, is an error.
108
+ `structure.deuterated(['CTA'])` returns the same change as a new structure.
109
+
110
+ ## File formats
111
+
112
+ Positions are in Å and SLDs in Å⁻² in all four (a `.gro` file's nm are converted).
113
+
114
+ | Format | What is read |
115
+ |---|---|
116
+ | PDB | `ATOM` records of the first model. `include_hetatm=True` adds `HETATM` records, never waters (`HOH`, `DOD`, `WAT` and the MD names `SOL`, `TIP3`, `TIP4`, `SPC`, `SPCE`, `OPC`). One alternate-location ID for the whole structure, occupancy not applied. Residue names from columns 18–21, where MD programs write four-letter names. Elements as below. Each element gets SasView's SLD and *natural atomic volume* from periodictable; an unknown element is an error. `CONECT` bonds are drawn. A `CRYST1` box is stored unless it is unreadable or a placeholder (a side of 1 Å or less). Residue numbers that are not decimal integers (hybrid-36) are renumbered in order. |
117
+ | GRO | All atoms, cut at GROMACS's fixed columns (names may touch numbers; `-ndec` files are read). Velocities are ignored; the box comes from the last line and is used to unwrap the aggregate. Elements as below. |
118
+ | SLD | One header line, then `x y z sld`, `x y z sld mx my mz` or `x y z sld mx my mz volume`. Magnetic columns are ignored; six-column magnetic-only files are refused. Without a volume column the points must form a lattice and each pixel's volume is the product of the steps. A sparse subset that skips planes looks like a coarser lattice, so give such files the volume column. |
119
+ | VTK | Legacy ASCII `UNSTRUCTURED_GRID` (classic or 5.1 layout) with tetrahedra, voxels, hexahedra, wedges and pyramids. The nuclear SLD is the one single-component `SCALARS` field, per cell or per point (averaged to cells as SasView does). Each cell becomes one point at its volume centroid. Binary files are refused with an export recipe. |
120
+
121
+ **Selecting residues.** `load_structure(path, residues=['CTA'])` reads only
122
+ the atoms of these residue names, from PDB and GRO files. When given, it is
123
+ the whole selection: the PDB water and `HETATM` rules do not apply. A `.gro`
124
+ file of a solvated system needs it; without it the reader notes how many
125
+ residue names it read. Options a format does not have are ignored with a
126
+ note. The structure keeps the options it was read with
127
+ (`structure.reader_options`), so `get_settings()` records them however the
128
+ structure was installed.
129
+
130
+ **Elements.** For PDB and GRO files each residue name gets its elements from
131
+ the first source that has them:
132
+
133
+ 1. `masses={'CTA': [14.007, 12.011, ...]}`: the atom masses in u in topology
134
+ order. Each mass gives the element of nearest mass, H to U and D, within
135
+ 0.01 u. Hydrogen mass repartitioning (H at 3.024 u) or united atoms (CH₂
136
+ at 14.027 u) are no element's and an error: pass `elements=` for them.
137
+ 2. `elements={'CTA': ['N', 'C', ...]}`: the element symbols in order.
138
+ 3. The PDB element column, when it is filled for every atom of the residue.
139
+ 4. A guess from the atom names, with a note. PDB files use SasView's column
140
+ rule (` CA ` is carbon, `CA` starting in column 13 calcium). GRO files have
141
+ no column convention: leading digits are dropped, then the first letter is
142
+ the element, except that a one-atom residue whose name is a two-letter
143
+ element (`NA`, `CL`) is that element. So `CA` in a protein is carbon and a
144
+ `CA` ion calcium. The note lists every two-letter guess, since those are
145
+ the likeliest to be wrong.
146
+
147
+ Masses and element lists are matched by the atom's position in its residue
148
+ (a run of atoms with one residue number and name), so the atom names of the structure
149
+ and the topology may differ. `structure.element_sources` records the source
150
+ per residue name. `structure.with_symbols(symbols)` returns a copy with
151
+ corrected elements, and `Structure.from_atoms(xyz, symbols, resids=,
152
+ resnames=, names=)` builds atoms that were read elsewhere.
153
+
154
+ **Periodic boxes.** GROMACS writes every position back into the box, which
155
+ can split molecules and the aggregate. With `unwrap='auto'` (the default) a
156
+ `.gro` file is unwrapped: each molecule is made whole around its first atom,
157
+ then moved by whole box lengths next to the aggregate's centre, the circular
158
+ mean of the molecule centres. A note counts the atoms moved. It works for one
159
+ aggregate smaller than half the box. A `.gro` file whose box is zero (no
160
+ periodic box) is read as it is, with a note. PDB files are unwrapped only with
161
+ `unwrap=True`, because many programs write a placeholder `CRYST1`; a missing
162
+ or placeholder box is then an error. Triclinic boxes cannot be unwrapped
163
+ (`unwrap=False` reads such files without the box).
164
+
165
+ The readers follow SasView's (`sas.sascalc.calculator.sas_gen`, BSD-3-Clause,
166
+ Copyright (c) 2009-2026, SasView Developers) with one deviation: the VTK cell
167
+ geometry is recomputed, because SasView's volumes for hexahedra, wedges and
168
+ pyramids are wrong. The GRO reader and the residue options come from
169
+ coords2sans.
170
+
171
+ ## Things to know
172
+
173
+ - **PDB structures in a solvent are only qualitative.** The natural atomic
174
+ volumes use bulk elemental densities, liquid H₂, N₂ and O₂ for H, N and O.
175
+ A typical residue gets about 310 ų instead of about 133 ų, so in D₂O a
176
+ protein comes out about 8× too intense at q → 0. There is no hydration
177
+ shell and no H/D exchange either. `calculate()` warns with the actual
178
+ volume ratio. In vacuum (`sld_solvent=0`) the result is right. For atoms,
179
+ `scale` is not a volume fraction.
180
+ - **Points stand in for cells.** Pixels and VTK cells are treated as points,
181
+ which degrades as q × cell size approaches 1. `calculate()` warns above
182
+ that, with the cell size taken as the lattice step or the cell diameter
183
+ divided by √3.
184
+ - **Kernels.** `set_params(kernel=...)` picks how D(q) is summed; the
185
+ profile's `method` records it.
186
+
187
+ | `kernel` | How | Exact? | Time |
188
+ |---|---|---|---|
189
+ | `'lattice'` | FFT autocorrelation of the pixel grid | yes | well under a second for 10⁵ pixels |
190
+ | `'histogram'` | pairs counted by distance, then one sum per q over the occupied distances | to about 1e-4 | about 2 s for 11 000 atoms, N² |
191
+ | `'average'` | ⟨A⟩²: each point's distance from the bounding-box centre | only for spherically symmetric structures | linear in N |
192
+ | `'pairs'` | every pair at every q | to the last digit | 2–3 s for 4 000 points, 25 s for 8 000 |
193
+
194
+ `'auto'` (the default) is `'lattice'` for pixels on a lattice and
195
+ `'histogram'` for everything else. The histogram bin is `'auto'`:
196
+ `min(0.01 Å, 1e-3 / (q_max · f_max))`, with f_max the largest size factor
197
+ (1 without polydispersity), which keeps the rounding phase error below
198
+ 5e-4 rad; `histogram_bin=` sets it, and `profile.histogram_bin` records it.
199
+ A cached spectrum with a finer bin is reused.
200
+ `'average'` reproduces SasView's `is_avg=True` and suits round aggregates
201
+ above about 10⁵ atoms. `calculate(progress=...)` can report progress or
202
+ stop the pair passes of `'histogram'` and `'pairs'`.
203
+ - **Contrast series are cheap.** The lattice, histogram and average kernels
204
+ keep the scattering lengths and the volumes apart in their distance
205
+ spectrum, so the calculator keeps the last one and a new `sld_solvent`,
206
+ `scale`, `background` or `normalisation_volume` costs only the sums over
207
+ q. A new structure, deuteration, kernel, a finer bin or a change of which
208
+ points match the solvent repeats the pair pass.
209
+ - **The curve is theory.** `profile.to_data1d()` returns a `Data1D` with no
210
+ dI and no dQ, with `qmin`, `qmax` and an empty `mask` set. A fit of it
211
+ should state its weighting and resolution explicitly; pass
212
+ `to_data1d(dI=...)` to attach uncertainties.
213
+ - **Reproducibility.** `gsc.get_settings()` records the file path and
214
+ SHA-256, the reader options, the q grid, every parameter and the package
215
+ versions. `GenericScatteringCalculator.from_settings(settings)` rebuilds
216
+ the calculator and warns if the file or the periodictable version changed;
217
+ records of version 0.1 replay with the new parameters at their defaults,
218
+ except the kernel: `'auto'` for pixels on a lattice and otherwise
219
+ `'pairs'`, the pair sum 0.1 used, so a record gives the curve it was
220
+ made with.
221
+ A structure no file and options can rebuild (`from_arrays`, `from_atoms`,
222
+ or a `deuterated` / `with_symbols` copy) is recorded as `'in-memory'`, which
223
+ `from_settings` refuses; a derived copy also records `derived_from`.
224
+ - **Series and worker processes.** `gsc.calculate_series(series)` computes
225
+ one profile per entry, each a `label` and `set_params` keys that apply to
226
+ that entry only. All entries are checked before anything runs, entries
227
+ that share a deuteration state and kernel share one pair pass, and the
228
+ profiles come back in the order given, each with its `label`. The
229
+ parameters are left as they were. A settings record plus a series is plain
230
+ JSON, so another process can run it:
231
+ `GenericScatteringCalculator.from_settings(settings).calculate_series(series)`.
232
+ Call `sinccity.set_verbosity('quiet')` there; the logger is per process.
233
+ - **Console output.** Progress lines go through the `sinccity` logger;
234
+ `sinccity.set_verbosity('quiet')` silences them.
235
+
236
+ ## Coming from coords2sans
237
+
238
+ `sinccity` takes over coords2sans's features; its names and units differ in
239
+ places.
240
+
241
+ | coords2sans | sinccity |
242
+ |---|---|
243
+ | `Structure.read(path, residues, masses, elements)` | `sinccity.load(path, residues=, masses=, elements=)` or `gsc.load_structure(...)` |
244
+ | `Structure(resids, resnames, names, positions, elements)` | `Structure.from_atoms(xyz, symbols, resids=, resnames=, names=)` |
245
+ | `structure.positions`, `structure.elements` (editable) | `structure.xyz`, `structure.symbols`; `structure.with_symbols(new)` |
246
+ | `structure.sld(deuterated=[...])` | `structure.deuterated([...]).sld`, or `set_params(deuterated=[...])` |
247
+ | `SansCalculator(solvent_sld=6.36e-6)` | `set_params(sld_solvent=6.36)`, in 10⁻⁶ Å⁻² |
248
+ | `q_min`, `q_max`, `q_points`, `q_log` | `set_q_grid(qmin, qmax, npoints, spacing='log' or 'linear')`; the default grid is SasView's, 3e-4 to 0.3 Å⁻¹ in 30 points |
249
+ | `polydispersity`, `size_points`, `SIZE_RANGE` | `size_pd`, `size_pd_n`, `size_pd_nsigma` |
250
+ | `average_amplitude=True` | `kernel='average'` |
251
+ | `BIN_WIDTH` (0.01 Å) | `histogram_bin`, by default at most 0.01 Å and smaller at high q or with a size spread |
252
+ | `calculate(structure, label)` → `Curve` | `set_structure(structure)`, then `calculate(label=)` → `ScatteringProfile` |
253
+ | `Curve.save`, `Curve.plot(ax)` | `profile.save_csv(path)` (the same two columns); `ax.plot(profile.q, profile.intensity)` or `profile.plot()` (plotly) |
254
+ | `SansTask` | `get_settings()` plus a series, run by `from_settings(settings).calculate_series(series)` |
255
+
256
+ Two rules differ on purpose. PDB files without an element column use
257
+ SasView's name rule (` CA ` is carbon), and in `.gro` files a two-letter
258
+ element name counts only in a one-atom residue (so `CA` in a protein is
259
+ carbon). V_norm is the volume of the atoms that scatter, which equals
260
+ coords2sans's volume of all atoms unless an atom matches the solvent.
261
+
262
+ ## Tests
263
+
264
+ ```bash
265
+ pytest tests
266
+ ```
267
+
268
+ ## Licence
269
+
270
+ BSD 3-Clause, like IQuana. The test structures in `tests/data/` are
271
+ SasView test data, BSD 3-Clause, Copyright (c) 2009-2026, SasView Developers.
@@ -0,0 +1,57 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "SincCity"
7
+ version = "0.2.0"
8
+ description = "Generic Scattering Calculator: I(q) of a real-space structure (PDB, GRO, SLD or VTK)"
9
+ requires-python = ">=3.10"
10
+ license = "BSD-3-Clause"
11
+ license-files = ["LICENSE"]
12
+ authors = [
13
+ {name = "SincCity contributors"}
14
+ ]
15
+ readme = "README.md"
16
+ dependencies = [
17
+ "numpy>=2.0",
18
+ "periodictable>=1.6",
19
+ ]
20
+
21
+ [project.urls]
22
+ Documentation = "https://easyscience.github.io/SincCity/"
23
+ Source = "https://github.com/easyscience/SincCity"
24
+
25
+ [project.optional-dependencies]
26
+ plot = [
27
+ "plotly>=5.0",
28
+ ]
29
+ sasdata = [
30
+ "sasdata>=0.8",
31
+ ]
32
+ docs = [
33
+ "mkdocs>=1.6,<2",
34
+ "mkdocs-material>=9.6",
35
+ "mkdocstrings[python]>=1.0",
36
+ ]
37
+ dev = [
38
+ "sinccity[plot,sasdata]",
39
+ "pytest>=7.0",
40
+ "ruff>=0.14",
41
+ ]
42
+
43
+ [tool.setuptools.packages.find]
44
+ where = ["src"]
45
+
46
+ [tool.ruff]
47
+ line-length = 100
48
+
49
+ [tool.ruff.format]
50
+ quote-style = "single"
51
+
52
+ [tool.pytest.ini_options]
53
+ testpaths = ["tests"]
54
+ addopts = ["-ra", "--strict-markers"]
55
+ markers = [
56
+ "slow: marks tests as slow (deselect with '-m \"not slow\"')",
57
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+