Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/Http/Controllers/StudyController.php
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ public function renderTabView($tab, $study, $team, $project, $license, $studyFSO
break;
case 'Datasets':
return Inertia::render('Study/Datasets', [
'study' => $study->load('users', 'owner', 'studyInvitations', 'datasets'),
'study' => $study->load('users', 'owner', 'studyInvitations', 'datasets', 'sample.molecules'),
'team' => $team ? $team->load('users', 'owner') : null,
'project' => $project ? $project->load('users', 'owner') : null,
'members' => $study->allUsers(),
Expand Down
1 change: 1 addition & 0 deletions app/Http/Resources/StudyResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ public function toArray($request): array
'external_id' => $this->external_id,
'external_url' => $this->external_url,
'processing_logs' => $this->processing_logs,
'hifsa_data' => $this->when(! $this->lite, fn () => $this->hifsa_data),
'stats' => [
'likes' => $this->likesCount(),
'views' => (int) $this->views,
Expand Down
182 changes: 175 additions & 7 deletions app/Support/Draft/HifsaPdfResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,9 @@ public function resolveExportZip(Study $study): ?FileSystemObject
* chemical_shifts: list<array<string, mixed>>,
* couplings: list<array<string, mixed>>,
* lineshapes: list<array<string, mixed>>,
* qmgi: list<array<string, mixed>>
* qmgi: list<array<string, mixed>>,
* structures?: array<string, string>,
* atom_maps?: array<string, array<string, int>>
* }|null
*/
public function readCsvData(FileSystemObject $zipFile): ?array
Expand Down Expand Up @@ -203,6 +205,8 @@ public function readCsvData(FileSystemObject $zipFile): ?array
}

$this->enrichFromRefCsvs($zip, $parsed);
$parsed['structures'] = $this->extractSpinSystemStructures($zip);
$parsed['atom_maps'] = $this->extractAtomMaps($zip);

return $parsed;
} finally {
Expand Down Expand Up @@ -655,7 +659,7 @@ private function mapChemicalShiftRow(array $row): array
'nucleus' => $this->toFloat($row['Nucleus'] ?? null),
'spincount' => $this->toFloat($row['Spincount'] ?? null),
'nucleicount' => $this->toFloat($row['Nucleicount'] ?? null),
'shift' => $this->toFloat($row['Shift'] ?? null),
'shift' => $this->toDisplayableShiftPpm($row['Shift'] ?? null),
'response' => $this->toFloat($row['Response'] ?? null),
'line_shape' => $this->nullableString($row['Line shape'] ?? null),
'lrms' => $this->toNullableFiniteFloat($row['LRMS'] ?? null),
Expand Down Expand Up @@ -687,7 +691,7 @@ private function mapCouplingRow(array $header, array $cells): array
'name' => $this->nullableString($this->cellByHeader($header, $cells, 'Name')),
'shift_from' => $this->nullableString($cells[$shiftIndexes[0] ?? -1] ?? null),
'shift_to' => $this->nullableString($cells[$shiftIndexes[1] ?? -1] ?? null),
'coupling' => $this->toFloat($this->cellByHeader($header, $cells, 'Coupling')),
'coupling' => $this->toDisplayableCouplingHz($this->cellByHeader($header, $cells, 'Coupling')),
];
}

Expand Down Expand Up @@ -822,6 +826,34 @@ private function toFloat(mixed $value): ?float
return (float) $value;
}

/**
* Cosmic Truth unset chemical shifts use huge sentinels (e.g. -1e12).
*/
private function toDisplayableShiftPpm(mixed $value): ?float
{
$float = $this->toFloat($value);

if ($float === null || ! is_finite($float) || abs($float) >= 1000) {
return null;
}

return $float;
}

/**
* Reject non-physical / sentinel coupling constants.
*/
private function toDisplayableCouplingHz(mixed $value): ?float
{
$float = $this->toFloat($value);

if ($float === null || ! is_finite($float) || abs($float) >= 1e6) {
return null;
}

return $float;
}

/**
* Like toFloat, but also rejects Cosmic Truth ND sentinels (-1, ±Infinity).
*/
Expand All @@ -845,25 +877,161 @@ private function toNullableFiniteFloat(mixed $value): ?float
}

/**
* True when hifsa_data already has scores and the section arrays introduced
* for the detail tables. Score-only payloads from earlier parses are treated
* as incomplete so they get upgraded from the export zip.
* True when hifsa_data already has scores, section arrays, and non-empty
* CT structures + atom maps. Empty maps/structures are incomplete so a
* later export with OUTPUT.json / spinsystems.sdf can upgrade the study.
*/
private function hasStructuredHifsaData(mixed $data): bool
{
if (! is_array($data) || ! isset($data['scores']) || ! is_array($data['scores'])) {
return false;
}

foreach (['spinsystems', 'chemical_shifts', 'couplings', 'lineshapes', 'qmgi'] as $key) {
foreach (['spinsystems', 'chemical_shifts', 'couplings', 'lineshapes', 'qmgi', 'structures', 'atom_maps'] as $key) {
if (! array_key_exists($key, $data) || ! is_array($data[$key])) {
return false;
}
}

if ($data['structures'] === [] || $data['atom_maps'] === []) {
return false;
}

return true;
}

/**
* Extract Cosmic Truth `spinsystems.sdf` (true 3D conformers) keyed by
* spin-system name / SDF title line.
*
* @return array<string, string>
*/
private function extractSpinSystemStructures(ZipArchive $zip): array
{
$sdfName = null;

for ($i = 0; $i < $zip->numFiles; $i++) {
$name = $zip->getNameIndex($i);

if (! is_string($name)) {
continue;
}

if (strcasecmp(basename($name), 'spinsystems.sdf') === 0) {
$sdfName = $name;
break;
}
}

if ($sdfName === null) {
return [];
}

$contents = $zip->getFromName($sdfName);

if ($contents === false || trim($contents) === '') {
return [];
}

$structures = [];

foreach (preg_split('/\$\$\$\$\s*/', $contents) ?: [] as $block) {
$block = trim($block);

if ($block === '') {
continue;
}

$lines = preg_split('/\r\n|\r|\n/', $block) ?: [];
$title = trim((string) ($lines[0] ?? ''));

if ($title === '') {
continue;
}

if (! str_contains($block, 'M END') && ! str_contains($block, 'M END')) {
continue;
}

$structures[$title] = $block."\n".'$$$$'."\n";
}

return $structures;
}

/**
* Build Cosmic Truth atom-name → 1-based SDF index maps from OUTPUT.json.
*
* CT labels like C34 / H4 are NOT SDF serials. Each atom entry has `n`
* (label) and `o` (0-based order in the mol/SDF); use o+1 as the SDF index.
*
* @return array<string, array<string, int>>
*/
private function extractAtomMaps(ZipArchive $zip): array
{
$maps = [];

for ($i = 0; $i < $zip->numFiles; $i++) {
$name = $zip->getNameIndex($i);

if (! is_string($name)) {
continue;
}

if (! preg_match('/_OUTPUT\.json$/i', $name)) {
continue;
}

$contents = $zip->getFromName($name);

if ($contents === false || trim($contents) === '') {
continue;
}

$json = json_decode($contents, true);

if (! is_array($json)) {
continue;
}

$spinSystem = trim((string) ($json['n'] ?? ''));
$atoms = $json['a'] ?? null;

if ($spinSystem === '' || ! is_array($atoms)) {
continue;
}

$map = [];

foreach ($atoms as $atom) {
if (! is_array($atom)) {
continue;
}

$label = trim((string) ($atom['n'] ?? ''));
$order = $atom['o'] ?? null;

if ($label === '' || ! is_numeric($order)) {
continue;
}

$index = ((int) $order) + 1;

if ($index < 1) {
continue;
}

$map[$label] = $index;
}

if ($map !== []) {
$maps[$spinSystem] = $map;
}
}

return $maps;
}

/**
* Parse a Cosmic Truth CSV line. Multi-atom group names are exported as
* `""C10,C11""` (doubled quotes around a comma-containing name without a
Expand Down
Loading
Loading