radfusion/app/Services/Pacs/Sync/StudiesSync.php

361 lines
14 KiB
PHP

<?php
namespace App\Services\Pacs\Sync;
use App\Domain\Study\Priority;
use App\Domain\Study\WorkflowLevel;
use App\Models\DicomServer;
use App\Services\Pacs\DicomUtils;
use App\Services\Pacs\OrthancRestClient;
use App\Services\StudyRouter\DicomStudyRouter;
use App\Services\StudyRouter\RawDicomTags;
use Carbon\Carbon;
use Exception;
use Illuminate\Pipeline\Pipeline;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
final class StudiesSync
{
public const SYNC_AGENT = '$$_pacs_agent_$$';
private Collection $study_ids;
private Collection $insert_queue;
private Collection $update_queue;
private Collection $archive_queue;
private OrthancRestClient $client;
public function __construct(private readonly DicomServer $dicomServer, ?OrthancRestClient $client = null, private readonly int $maxInstances = 3)
{
$this->study_ids = collect();
$this->client = $client ?? new OrthancRestClient($dicomServer);
$this->resetQueues();
}
public function getDicomServer(): DicomServer
{
return $this->dicomServer;
}
public function execute(): void
{
app(Pipeline::class)
->send($this)
->through([
Pipes\ScanStudies::class,
Pipes\FilterStudies::class,
Pipes\InsertStudies::class,
Pipes\UpdateStudies::class,
Pipes\ArchiveStudies::class,
])
->thenReturn();
}
public function getClient(): OrthancRestClient
{
return $this->client;
}
public function getStudyIds(): Collection
{
return $this->study_ids;
}
public function setStudyIds(array $study_ids): void
{
$this->study_ids = collect($study_ids);
}
public function resetQueues(): void
{
$this->insert_queue = collect();
$this->update_queue = collect();
$this->archive_queue = collect();
}
public function getInsertQueue(): Collection
{
return $this->insert_queue;
}
public function getUpdateQueue(): Collection
{
return $this->update_queue;
}
public function getArchiveQueue(): Collection
{
return $this->archive_queue;
}
public function fetchStudyDetails(string $orthanc_uuid): ?array
{
$study = $this->client->getStudyDetails($orthanc_uuid);
if ($study == null) {
return null;
}
$stats = $this->client->getStudyStatistics($orthanc_uuid);
$study['Statistics'] = $stats;
$series = $this->client->getStudySeries($orthanc_uuid);
$study['Series'] = $series;
return $study;
}
public function fetchInstancesList(string $orthanc_uuid): ?array
{
return $this->client->getStudyInstancesList($orthanc_uuid);
}
public function fetchInstancesTags(string $orthanc_uuid): ?array
{
return $this->client->getInstanceDetails($orthanc_uuid, true);
}
public function getStudyDescription(mixed $orthanc_src): ?string
{
$result = data_get($orthanc_src, 'MainDicomTags.StudyDescription');
if (blank($result)) {
$result = data_get($orthanc_src, 'RequestedTags.AcquisitionDeviceProcessingDescription');
}
if (blank($result)) {
$result = data_get($orthanc_src, 'MainDicomTags.AcquisitionDeviceProcessingDescription');
}
return $result;
}
public function transformData(mixed $orthanc_src): array
{
$stable_study = (bool) data_get($orthanc_src, 'IsStable', false);
if (! $stable_study) {
// do not process unstable studies.
// defer till next sync when hopefully the study becomes stable
return [];
}
$orthanc_uuid = strtolower($orthanc_src['ID']);
$dicomHeaders = $this->getStudyDicomTags($orthanc_uuid);
$routing = DicomStudyRouter::matchStudy($dicomHeaders);
$inst_name = data_get($orthanc_src, 'MainDicomTags.InstitutionName');
$patient_name = data_get($orthanc_src, 'PatientMainDicomTags.PatientName');
$name_parts = tokenizeString($patient_name);
$patient_age = data_get($orthanc_src, 'RequestedTags.PatientAge');
if (blank($patient_age) && ! empty($name_parts)) {
// try to get age from last part of patient name
$last = end($name_parts);
if (preg_match('/^\d+[YMD]$/i', $last)) {
$patient_age = $last;
/*
// sanitize patient name
array_pop($name_parts);
$patient_name = implode(' ', $name_parts);
*/
}
}
if ($patient_age !== null) {
$age = strtoupper(ltrim($patient_age, '0'));
if (strlen($age) > 1) {
$patient_age = $age;
}
}
// $patient_name = trim($patient_name, '.^ ');
$descr = $this->getStudyDescription($orthanc_src);
$study = [
'dicom_server_id' => $this->dicomServer->id,
'orthanc_uuid' => $orthanc_uuid,
'institution_name' => $inst_name,
'organization_id' => $routing['organization_id'],
'department_id' => $routing['department_id'],
'patient_uuid' => strtolower($orthanc_src['ParentPatient']),
'patient_id' => data_get($orthanc_src, 'PatientMainDicomTags.PatientID'),
'patient_name' => $patient_name,
'patient_sex' => data_get($orthanc_src, 'PatientMainDicomTags.PatientSex'),
'patient_age' => $patient_age,
'accession_number' => data_get($orthanc_src, 'MainDicomTags.AccessionNumber'),
'referring_physician_name' => data_get($orthanc_src, 'MainDicomTags.ReferringPhysicianName'),
'study_id' => data_get($orthanc_src, 'MainDicomTags.StudyID'),
'study_instance_uid' => data_get($orthanc_src, 'MainDicomTags.StudyInstanceUID'),
'modality' => data_get($orthanc_src, 'RequestedTags.Modality'),
'body_part_examined' => data_get($orthanc_src, 'RequestedTags.BodyPartExamined'),
'study_date' => DicomUtils::dateTimeToCarbon($orthanc_src['MainDicomTags']['StudyDate'], $orthanc_src['MainDicomTags']['StudyTime']) ?? Carbon::createFromTimestamp(0),
'received_at' => Carbon::parse($orthanc_src['LastUpdate'], 'UTC'),
'image_count' => data_get($orthanc_src, 'Statistics.CountInstances'),
'series_count' => data_get($orthanc_src, 'Statistics.CountSeries'),
'disk_size' => data_get($orthanc_src, 'Statistics.DiskSize'),
'study_description' => $descr,
];
$study['workflow_level'] = $stable_study
? WorkflowLevel::Unassigned->value
: WorkflowLevel::Received->value;
$patient_birthdate = null;
$dob = data_get($orthanc_src, 'PatientMainDicomTags.PatientBirthDate');
if (filled($dob)) {
try {
$patient_birthdate = Carbon::parse($dob);
} catch (Exception $e) {
Log::error('Failed to parse PatientMainDicomTags.PatientBirthDate: {dob}', ['dob' => $dob, 'exception' => $e->getMessage()]);
}
}
if ($patient_birthdate == null && $patient_age !== null) {
try {
// $age = (int) preg_replace('/[^0-9]/', '', $patient_age);
$age_num = (int) filter_var($patient_age, FILTER_SANITIZE_NUMBER_INT);
$now = now();
switch (strtoupper(substr($patient_age, -1))) {
case 'Y':
$patient_birthdate = $now->subYears($age_num);
break;
case 'M':
$patient_birthdate = $now->subMonths($age_num);
break;
case 'D':
$patient_birthdate = $now->subDays($age_num);
break;
}
} catch (Exception) {
Log::error('Failed to parse patient_age: {age}', ['age' => $patient_age]);
}
}
$study['patient_birthdate'] = $patient_birthdate;
// check for priority in patient name or description
if (preg_match('/\b(urgent|stat)\b/i', implode(' ', [$descr, $patient_name]))) {
$this->setValue($study, 'priority', Priority::Stat->value);
}
$properties = [
'other_patient_names' => data_get($orthanc_src, 'RequestedTags.OtherPatientNames'),
'other_patient_ids' => data_get($orthanc_src, 'RequestedTags.OtherPatientIDs'),
'software_versions' => data_get($orthanc_src, 'RequestedTags.SoftwareVersions'),
'station_name' => data_get($orthanc_src, 'RequestedTags.StationName'),
'operators_name' => data_get($orthanc_src, 'RequestedTags.OperatorsName'),
'manufacturer' => data_get($orthanc_src, 'RequestedTags.Manufacturer'),
'manufacturer_model_name' => data_get($orthanc_src, 'RequestedTags.ManufacturerModelName'),
'acquisition_date' => DicomUtils::dateTimeToCarbon(data_get($orthanc_src, 'RequestedTags.AcquisitionDate'), data_get($orthanc_src, 'RequestedTags.AcquisitionTime')),
];
$properties = array_purge($properties);
if (empty($properties)) {
$properties = [
'other_patient_names' => data_get($orthanc_src, 'MainDicomTags.OtherPatientNames'),
'other_patient_ids' => data_get($orthanc_src, 'MainDicomTags.OtherPatientIDs'),
'software_versions' => data_get($orthanc_src, 'MainDicomTags.SoftwareVersions'),
'station_name' => data_get($orthanc_src, 'MainDicomTags.StationName'),
'operators_name' => data_get($orthanc_src, 'MainDicomTags.OperatorsName'),
'manufacturer' => data_get($orthanc_src, 'MainDicomTags.Manufacturer'),
'manufacturer_model_name' => data_get($orthanc_src, 'MainDicomTags.ManufacturerModelName'),
'acquisition_date' => DicomUtils::dateTimeToCarbon(data_get($orthanc_src, 'MainDicomTags.AcquisitionDate'), data_get($orthanc_src, 'MainDicomTags.AcquisitionTime')),
];
$properties = array_purge($properties);
}
$series = [];
foreach (data_get($orthanc_src, 'Series', []) as $ser) {
$params = [
'orthanc_uuid' => strtolower($ser['ID']),
'series_instance_uid' => data_get($ser, 'MainDicomTags.SeriesInstanceUID'),
'series_date' => DicomUtils::dateTimeToCarbon(data_get($ser, 'MainDicomTags.SeriesDate'), data_get($ser, 'MainDicomTags.SeriesTime')),
'series_number' => data_get($ser, 'MainDicomTags.SeriesNumber'),
'series_description' => data_get($ser, 'MainDicomTags.SeriesDescription'),
'protocol_name' => data_get($ser, 'MainDicomTags.ProtocolName'),
'modality' => data_get($ser, 'MainDicomTags.Modality'),
'body_part_examined' => data_get($ser, 'MainDicomTags.BodyPartExamined'),
'performed_procedure_step_description' => data_get($ser, 'MainDicomTags.PerformedProcedureStepDescription'),
'sequence_name' => data_get($ser, 'MainDicomTags.SequenceName'),
];
$params['num_instances'] = count(data_get($ser, 'Instances', []));
$params = array_purge($params);
if (! empty($params)) {
$series[] = $params;
}
}
if (empty($series)) {
$series = null;
} else {
// $series = array_multisort(array_column($series, 'series_number'), SORT_ASC, $series);
usort($series, fn ($a, $b): int => (int) $a['series_number'] <=> (int) $b['series_number']);
}
if (empty($properties)) {
$properties = null;
}
$dicom_properties = array_purge([
'patient_id' => $study['patient_id'],
'patient_name' => $study['patient_name'],
'patient_birthdate' => $study['patient_birthdate'],
'patient_age' => $study['patient_age'],
'patient_sex' => $study['patient_sex'],
'accession_number' => $study['accession_number'],
'referring_physician_name' => $study['referring_physician_name'],
'study_id' => $study['study_id'],
'body_part_examined' => $study['body_part_examined'],
'study_date' => $study['study_date'],
'study_description' => $study['study_description'],
]);
$details = array_purge(compact('properties', 'series', 'dicom_properties'));
$study = array_purge($study);
// todo: handle $routing['radiologists']
return compact('study', 'details');
}
private function getStudyDicomTags(string $study_uuid): array
{
$instances = $this->fetchInstancesList($study_uuid);
if (empty($instances)) {
return [];
}
// randomly sample few instances for tags collection
$selectedInstances = count($instances) <= $this->maxInstances
? $instances
: array_intersect_key($instances, array_flip(array_rand($instances, $this->maxInstances)));
$tags = collect();
foreach ($selectedInstances as $instance) {
foreach ($this->fetchInstancesTags($instance) as $key => $value) {
if ($key == 'MainDicomTags' || $key == 'RequestedTags') {
foreach ($value as $tag => $val) {
$dcmTag = RawDicomTags::tryFrom($tag);
if ($dcmTag != null && (! $tags->has($dcmTag->name) || $tags->get($dcmTag->name) !== $val)) {
$tags->put($dcmTag->name, $val);
}
}
}
}
}
return $tags->toArray();
}
private function setValue(array &$array, string $key, mixed $value): void
{
if (filled($value)) {
$array[$key] = $value;
}
}
}