diff --git a/.github/workflows/reusable_build.yml b/.github/workflows/reusable_build.yml index 6ab9aef..635e975 100644 --- a/.github/workflows/reusable_build.yml +++ b/.github/workflows/reusable_build.yml @@ -13,6 +13,18 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v2 + - name: Install Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + - name: Setup env + run: | + cd TMIAutomation\Server + pip install . -r requirements.txt + - name: Build server app + run: | + cd TMIAutomation\Server + pyinstaller --clean --noconfirm --add-data "models;models" --add-data "config.yml;." --collect-submodules=pydicom src/app.py - name: Add nuget to PATH uses: nuget/setup-nuget@v1 - name: Add msbuild to PATH diff --git a/.gitignore b/.gitignore index 385e9ed..a0850ed 100644 --- a/.gitignore +++ b/.gitignore @@ -366,4 +366,5 @@ FodyWeavers.xsd TMIAutomation.Tests/Configuration/SensitiveData.txt TMIAutomation/Library/ Docs/* -!Docs/*.pdf \ No newline at end of file +!Docs/*.pdf +TMIAutomation.Tests/Dicoms/ \ No newline at end of file diff --git a/Docs/TMIAutomation.pdf b/Docs/TMIAutomation.pdf index 840e71e..3d982bf 100644 Binary files a/Docs/TMIAutomation.pdf and b/Docs/TMIAutomation.pdf differ diff --git a/README.md b/README.md index 5388561..01cf5b1 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,15 @@ Plug-in script for the Eclipse Treatment Planning System to automate Total Marrow (Lymph-node) Irradiation (TMI/TMLI). -The script was introduced and validated in [this paper](https://doi.org/10.1007/s00066-022-02014-0). +The script was introduced and validated in [this paper](https://doi.org/10.1007/s00066-022-02014-0) on Strahlentherapie und Onkologie. + +The generation of the field geometry of TMI/TMLI with deep-learning was introduced in [this paper](https://aapm.onlinelibrary.wiley.com/doi/10.1002/mp.17089) on Medical Physics. If you liked/used this project, don't forget to give it a star! :star: ## Key features +* Field geometry for the TMI/TMLI upper-body generated with deep-learning models * Automatic planning of the lower-extremities for TMI/TMLI * Extendible to VMAT-TBI (Total Body Irradiation delivered with Volumetric Modulated Arc Therapy) @@ -37,9 +40,6 @@ If you are on a research workstation (_TBox_), you can immediately run the scrip by setting the database in research mode from the RT Administration module of Eclipse. Otherwise, the plug-in script `TMIAutomation.esapi.dll` needs to be **approved** in the Eclipse application. -The `Configuration` folder contains config files with objectives and parameters to optimize the lower-extremities plan. -Example values are provided in order for the script to execute properly. - ## Contributing Any contribution/feedback is **greatly appreciated**! diff --git a/TMIAutomation.Runner/TMIAutomation.Runner.csproj b/TMIAutomation.Runner/TMIAutomation.Runner.csproj index 598c456..2f4e8cd 100644 --- a/TMIAutomation.Runner/TMIAutomation.Runner.csproj +++ b/TMIAutomation.Runner/TMIAutomation.Runner.csproj @@ -179,6 +179,12 @@ IF %25ERRORLEVEL%25 GEQ 8 exit 1 IF $(ConfigurationName) == Debug-15.6 robocopy $(SolutionDir)$(SolutionName)\Configuration\ESAPI15 $(TargetDir)Configuration /mir IF %25ERRORLEVEL%25 GEQ 8 exit 1 +robocopy $(SolutionDir)$(SolutionName)\Configuration $(TargetDir)Configuration OARNames.txt +IF %25ERRORLEVEL%25 GEQ 8 exit 1 +robocopy $(SolutionDir)$(SolutionName)\Configuration $(TargetDir)Configuration DCMExport.txt +IF %25ERRORLEVEL%25 GEQ 8 exit 1 +robocopy $(SolutionDir)$(SolutionName)\Server\dist $(TargetDir)dist /mir /ns /nc /nfl /ndl +IF %25ERRORLEVEL%25 GEQ 8 exit 1 exit 0 \ No newline at end of file diff --git a/TMIAutomation.Tests/Configuration/ESAPI15/OptimizationOptions.txt b/TMIAutomation.Tests/Configuration/ESAPI15/OptimizationOptions.txt index 5c2bd8f..8b6cc2c 100644 --- a/TMIAutomation.Tests/Configuration/ESAPI15/OptimizationOptions.txt +++ b/TMIAutomation.Tests/Configuration/ESAPI15/OptimizationOptions.txt @@ -1,5 +1,9 @@ # Values must be tab separated # Lines starting with a hash sign '#' are ignored # Empty lines are ignored -OptimizationAlgorithm DoseAlgorithm MLCID DosePerFraction NumberOfFractions -PO_15.6.06 AAA 15.06.06 MLC0010 2 1 \ No newline at end of file +OptimizationAlgorithm PO_15.6.06 +DoseAlgorithm AAA 15.06.06 +MLCID MLC0010 +DosePerFraction 2 +NumberOfFractions 1 +TreatmentMachine TrueBeamSN1015 \ No newline at end of file diff --git a/TMIAutomation.Tests/Configuration/ESAPI16/OptimizationOptions.txt b/TMIAutomation.Tests/Configuration/ESAPI16/OptimizationOptions.txt index e9dcbd2..473efa4 100644 --- a/TMIAutomation.Tests/Configuration/ESAPI16/OptimizationOptions.txt +++ b/TMIAutomation.Tests/Configuration/ESAPI16/OptimizationOptions.txt @@ -1,5 +1,9 @@ # Values must be tab separated # Lines starting with a hash sign '#' are ignored # Empty lines are ignored -OptimizationAlgorithm DoseAlgorithm MLCID DosePerFraction NumberOfFractions -PO 16.1.0 AAA 15.06.06 MLC0010 2 1 \ No newline at end of file +OptimizationAlgorithm PO 16.1.0 +DoseAlgorithm AAA 15.06.06 +MLCID MLC0010 +DosePerFraction 2 +NumberOfFractions 1 +TreatmentMachine TrueBeamSN1015 \ No newline at end of file diff --git a/TMIAutomation.Tests/Configuration/OARNames.txt b/TMIAutomation.Tests/Configuration/OARNames.txt new file mode 100644 index 0000000..5c9a109 --- /dev/null +++ b/TMIAutomation.Tests/Configuration/OARNames.txt @@ -0,0 +1,12 @@ +# Lines starting with a hash sign '#' are ignored +# Empty lines are ignored +brain +encefalo +lung +polmone +liver +fegato +bowel +intestino +bladder +vescica \ No newline at end of file diff --git a/TMIAutomation.Tests/EntryPoint.cs b/TMIAutomation.Tests/EntryPoint.cs index cd1318a..bdf2ba4 100644 --- a/TMIAutomation.Tests/EntryPoint.cs +++ b/TMIAutomation.Tests/EntryPoint.cs @@ -17,30 +17,28 @@ internal class EntryPoint [STAThread] public static void Main(string[] args) { + ConfigOptOptions.Init(); + using (EclipseApp = Application.CreateApplication()) { string patientID = testData["PatientID"]; Patient patient = EclipseApp.OpenPatientById(patientID); Course course = patient.Courses.FirstOrDefault(c => c.Id == testData["CourseID"]); - PlanSetup planSetup = course.PlanSetups.FirstOrDefault(ps => ps.Id == testData["PlanID"]); - PluginScriptContext scriptContext = new PluginScriptContext - { - Patient = patient, - Course = course, - PlanSetup = planSetup, - StructureSet = planSetup.StructureSet - }; - EsapiWorker esapiWorker = new EsapiWorker(scriptContext); + PlanSetup planSetupLower = course.PlanSetups.FirstOrDefault(ps => ps.Id == testData["PlanIDLower"]); + PlanSetup planSetupUpper = course.PlanSetups.FirstOrDefault(ps => ps.Id == testData["PlanIDUpper"]); + + SetUpContext(patient, course, planSetupLower, out PluginScriptContext scriptContextLower, out EsapiWorker esapiWorkerLower); patient.BeginModifications(); try { TestBuilder.Create() - .Add(new ModelBase(esapiWorker), scriptContext) - .Add(scriptContext.PlanSetup.OptimizationSetup, scriptContext.PlanSetup) - .Add(scriptContext.PlanSetup, scriptContext) - .Add(scriptContext.PlanSetup, scriptContext) - .Add(scriptContext.StructureSet); + .Add(new ModelBase(esapiWorkerLower), scriptContextLower) + .Add(scriptContextLower.PlanSetup.OptimizationSetup, scriptContextLower.PlanSetup) + .Add(scriptContextLower.PlanSetup, scriptContextLower) + .Add(scriptContextLower.PlanSetup, planSetupUpper, scriptContextLower) + .Add(scriptContextLower.StructureSet) + .AddStatic(patientID); TestBase.RunTests(); } catch (Exception e) @@ -54,6 +52,18 @@ public static void Main(string[] args) } } + private static void SetUpContext(Patient patient, Course course, PlanSetup planSetupLower, out PluginScriptContext scriptContextLower, out EsapiWorker esapiWorkerLower) + { + scriptContextLower = new PluginScriptContext + { + Patient = patient, + Course = course, + PlanSetup = planSetupLower, + StructureSet = planSetupLower.StructureSet + }; + esapiWorkerLower = new EsapiWorker(scriptContextLower); + } + private static Dictionary InitializeTestData() { Dictionary dict = new Dictionary(); diff --git a/TMIAutomation.Tests/TMIAutomation.Tests.csproj b/TMIAutomation.Tests/TMIAutomation.Tests.csproj index 34eba1b..81bc715 100644 --- a/TMIAutomation.Tests/TMIAutomation.Tests.csproj +++ b/TMIAutomation.Tests/TMIAutomation.Tests.csproj @@ -64,6 +64,7 @@ + @@ -105,6 +106,7 @@ + Always @@ -121,7 +123,11 @@ IF $(ConfigurationName) == Debug-16.1 robocopy $(ProjectDir)Configuration\ESAPI16 $(TargetDir)Configuration /mir IF %25ERRORLEVEL%25 GEQ 8 exit 1 IF $(ConfigurationName) == Debug-15.6 robocopy $(ProjectDir)Configuration\ESAPI15 $(TargetDir)Configuration /mir -robocopy $(ProjectDir)Configuration $(TargetDir)Configuration PointOptimizationObjectives.txt EUDOptimizationObjectives.txt SensitiveData.txt +robocopy $(ProjectDir)Configuration $(TargetDir)Configuration PointOptimizationObjectives.txt EUDOptimizationObjectives.txt SensitiveData.txt OARNames.txt +IF %25ERRORLEVEL%25 GEQ 8 exit 1 +robocopy $(SolutionDir)$(SolutionName)\Server\dist $(TargetDir)dist /mir /ns /nc /nfl /ndl +IF %25ERRORLEVEL%25 GEQ 8 exit 1 +robocopy $(ProjectDir)Dicoms $(TargetDir)Dicoms /mir /ns /nc /nfl /ndl IF %25ERRORLEVEL%25 GEQ 8 exit 1 exit 0 diff --git a/TMIAutomation.Tests/TestBuilder/TestBuilder.cs b/TMIAutomation.Tests/TestBuilder/TestBuilder.cs index f6fe36b..db286d4 100644 --- a/TMIAutomation.Tests/TestBuilder/TestBuilder.cs +++ b/TMIAutomation.Tests/TestBuilder/TestBuilder.cs @@ -38,5 +38,12 @@ public static TestBuilder Create() throw new ArgumentException($"Could not find a private testObject of type {testObject.GetType()} in {testBase.GetType()}"); } } + + public TestBuilder AddStatic(params object[] optParams) where T : ITestBase, new() + { + T testBase = new T(); + testBase.Init(null, optParams).DiscoverTests(); + return testBuilderInstance; + } } } \ No newline at end of file diff --git a/TMIAutomation.Tests/Tests/CalculationTests.cs b/TMIAutomation.Tests/Tests/CalculationTests.cs index 9c7f632..b04aa6d 100644 --- a/TMIAutomation.Tests/Tests/CalculationTests.cs +++ b/TMIAutomation.Tests/Tests/CalculationTests.cs @@ -19,11 +19,11 @@ public override ITestBase Init(object testObject, params object[] optParams) [Theory] [InlineData("LowerBase")] - [InlineData("LowerBase1")] - private void AddBaseDosePlan(string expectedPlanId) + [InlineData("LowerBase")] + private void GetOrCreateBaseDosePlan(string expectedPlanId) { Course targetCourse = externalPlanSetup.Course; - ExternalPlanSetup newPlan = targetCourse.AddBaseDosePlan(externalPlanSetup.StructureSet); + ExternalPlanSetup newPlan = targetCourse.GetOrCreateBaseDosePlan(externalPlanSetup.StructureSet); try { Assert.Equal(expectedPlanId, newPlan.Id); diff --git a/TMIAutomation.Tests/Tests/ClientTests.cs b/TMIAutomation.Tests/Tests/ClientTests.cs new file mode 100644 index 0000000..54741b1 --- /dev/null +++ b/TMIAutomation.Tests/Tests/ClientTests.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading; +using TMIAutomation.Tests.Attributes; +using Xunit; +using Xunit.Sdk; + + +namespace TMIAutomation.Tests +{ + class ClientTests : TestBase + { + private string patientID; + private static readonly string executingPath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + + public override ITestBase Init(object testObject, params object[] optParams) + { + this.patientID = optParams.OfType().FirstOrDefault() as string; + return this; + } + + private static Process StartLocalServer() + { + string serverDirectory = Path.Combine(executingPath, "dist", "app"); + string serverPath = Path.Combine(serverDirectory, "app.exe"); + ProcessStartInfo startInfo = new ProcessStartInfo(serverPath) + { + WorkingDirectory = serverDirectory, + WindowStyle = ProcessWindowStyle.Minimized + }; + + return Process.Start(startInfo); + } + + [Theory] + [MemberData(nameof(GetFieldGeometry_Data))] + private void GetFieldGeometry(string modelName, + List> expectedIsocenters, + List> expectedJawX, + List> expectedJawY) + { + Process serverProcess = StartLocalServer(); + try + { + Thread.Sleep(TimeSpan.FromSeconds(20)); // bad practice but easier way to wait the server startup + Dictionary>> fieldGeometry = Client.GetFieldGeometry(modelName, + Path.Combine(executingPath, "Dicoms", this.patientID), + "PTV_totFIN_Crop", + new List { "Encefalo", "Polmone SX", "Polmone DX", "Fegato", "Intestino", "Vescica" }); + + List> isocenters = Assert.Contains("Isocenters", fieldGeometry as IReadOnlyDictionary>>); + List> jawX = Assert.Contains("Jaw_X", fieldGeometry as IReadOnlyDictionary>>); + List> jawY = Assert.Contains("Jaw_Y", fieldGeometry as IReadOnlyDictionary>>); + + for (int i = 0; i < isocenters.Count; ++i) + { + for (int j = 0; j < isocenters[i].Count; ++j) + { + Assert.Equal(expectedIsocenters[i][j], isocenters[i][j], 2); + } + + for (int j = 0; j < jawX[i].Count; ++j) + { + Assert.Equal(expectedJawX[i][j], jawX[i][j], 2); + Assert.Equal(expectedJawY[i][j], jawY[i][j], 2); + } + + } + } + catch (ContainsException e) + { + throw new Exception($"Could not find expected dictionary key", e); + } + catch (EqualException) + { + // Re-throw exception to shut down server + throw; + } + finally + { + serverProcess?.CloseMainWindow(); + } + } + + public static IEnumerable GetFieldGeometry_Data() + { + yield return new object[] { + "body_cnn", + new List> { // isocenters + new List { 25.98, 118.0, -844.55 }, + new List { 25.98, 118.0, -844.55 }, + new List { 25.98, 118.0, -640.87 }, + new List { 25.98, 118.0, -640.87 }, + new List { 25.98, 118.0, -423.88 }, + new List { 25.98, 118.0, -423.88 }, + new List { 25.98, 118.0, -206.89 }, + new List { 25.98, 118.0, -206.89 }, + new List { 25.98, 118.0, -4.55 }, + new List { 25.98, 118.0, -4.55 }, + new List { -300.0, 118.0, 163.40 }, + new List { -300.0, 118.0, 163.40 }, + }, + new List> { // jawX + new List { -15.51, 126.84 }, + new List { -157.05, 14.18 }, + new List { -9.55, 109.27 }, + new List { -126.84, 11.54 }, + new List { -8.99, 138.03 }, + new List { -117.72, 8.99 }, + new List { -8.36, 110.68 }, + new List { -128.96, 8.36 }, + new List { -9.48, 122.95 }, + new List { -117.56, 9.48 }, + new List { -200.0, -200.0 }, + new List { -200.0, -200.0 }, + }, + new List> { // jawY + new List { -200, 200 }, + new List { -200, 200 }, + new List { -200, 200 }, + new List { -200, 200 }, + new List { -200, 200 }, + new List { -200, 200 }, + new List { -200, 200 }, + new List { -200, 200 }, + new List { -121.48, 130.08 }, + new List { -117.84, 120.81 }, + new List { -163.40, -163.40 }, + new List { -163.40, -163.40 }, + } + }; + yield return new object[] { + "arms_cnn", + new List> { // isocenters + new List { 25.98, 118.0, -811.53 }, + new List { 25.98, 118.0, -811.53 }, + new List { 25.98, 118.0, -534.10 }, + new List { 25.98, 118.0, -534.10 }, + new List { 25.98, 118.0, 163.40 }, + new List { 25.98, 118.0, 163.40 }, + new List { 25.98, 118.0, -260.63 }, + new List { 25.98, 118.0, -260.63 }, + new List { 25.98, 118.0, -16.73 }, + new List { 25.98, 118.0, -16.73 }, + new List { -185.50, 118.0, -510.19 }, + new List { 201.95, 118.0, -510.19 }, + }, + new List> { // jawX + new List { -28.67, 185.08 }, + new List { -190.07, 13.12 }, + new List { -3.75, 179.77 }, + new List { -142.34, 1.25 }, + new List { 25.98, 25.98 }, + new List { 25.98, 25.98 }, + new List { -10.19, 161.93 }, + new List { -143.70, 10.19 }, + new List { -10.85, 135.13 }, + new List { -120.82, 10.85 }, + new List { -80.12, 64.34 }, + new List { -75.08, 78.40 }, + }, + new List> { // jawY + new List { -200.0, 200.0 }, + new List { -200, 200 }, + new List { -200, 200 }, + new List { -200, 200 }, + new List { -163.40, -163.40 }, + new List { -163.40, -163.40 }, + new List { -200, 200 }, + new List { -200, 200}, + new List { -123.95, 119.84 }, + new List { -133.82, 133.24 }, + new List { -200.0, 200.0 }, + new List { -200.0, 200.0 }, + } + }; + } + + } +} \ No newline at end of file diff --git a/TMIAutomation.Tests/Tests/IsocenterTests.cs b/TMIAutomation.Tests/Tests/IsocenterTests.cs index ec625d7..0555567 100644 --- a/TMIAutomation.Tests/Tests/IsocenterTests.cs +++ b/TMIAutomation.Tests/Tests/IsocenterTests.cs @@ -11,12 +11,14 @@ namespace TMIAutomation.Tests { public class IsocenterTests : TestBase { - private ExternalPlanSetup targetPlan; + private ExternalPlanSetup planInContext; + private ExternalPlanSetup upperPlan; private PluginScriptContext scriptContext; public override ITestBase Init(object testObject, params object[] optParams) { - this.targetPlan = testObject as ExternalPlanSetup; + this.planInContext = testObject as ExternalPlanSetup; + this.upperPlan = optParams.OfType().FirstOrDefault() as ExternalPlanSetup; this.scriptContext = optParams.OfType().FirstOrDefault(); return this.scriptContext == null ? throw new ArgumentException($"A PluginScriptContext must be provided to instantiate {this.GetType()}") @@ -36,9 +38,9 @@ private void CopyCaudalIsocenter(string sourcePlanId, ExternalPlanSetup sourcePlan = this.scriptContext.Course.ExternalPlanSetups.FirstOrDefault(ps => ps.Id == sourcePlanId); Registration registration = this.scriptContext.Patient.Registrations.FirstOrDefault(reg => reg.Id == registrationId); - targetPlan.CopyCaudalIsocenter(sourcePlan, registration); + planInContext.CopyCaudalIsocenter(sourcePlan, registration); - Beam newBeam = targetPlan.Beams.FirstOrDefault(b => b.Id == expectedBeamId); + Beam newBeam = planInContext.Beams.FirstOrDefault(b => b.Id == expectedBeamId); try { Assert.Equal(expectedGantryDir, newBeam.GantryDirection); @@ -57,7 +59,7 @@ private void CopyCaudalIsocenter(string sourcePlanId, finally { // Teardown - targetPlan.Beams.ToList().ForEach(beam => targetPlan.RemoveBeam(beam)); + planInContext.Beams.ToList().ForEach(beam => planInContext.RemoveBeam(beam)); } } @@ -66,5 +68,193 @@ public static IEnumerable CopyCaudalIsocenter_Data() yield return new object[] { "RA_TMLIup3", "REGISTRATION", "Field 9", GantryDirection.Clockwise, 180.1, 179.9, 175 }; yield return new object[] { "RA_TMLIup3", "REGISTRATION", "Field 10", GantryDirection.CounterClockwise, 179.9, 180.1, 185 }; } + + [Fact] + private void SetIsocentersUpperBodyCNN() + { + List> isocenters = new List> + { + new List { 26.1, 118.0, -858.4 }, // pelvis + new List { 26.1, 118.0, -858.4 }, + new List { 26.1, 118.0, -629.0 }, // abdomen + new List { 26.1, 118.0, -629.0 }, + new List { 26.1, 118.0, -417.7 }, // thorax + new List { 26.1, 118.0, -417.7 }, + new List { 26.1, 118.0, -206.3 }, // shoulders + new List { 26.1, 118.0, -206.3 }, + new List { 26.1, 118.0, -1.8 }, // head + new List { 26.1, 118.0, -1.8 }, + new List { -300, 118.0, 163.4 }, // arms + new List { -300, 118.0, 163.4 }, + }; + List> jawX = new List> + { + new List { -19.1, 168.2 }, // pelvis + new List { -169.8, 20.8 }, + new List { -9.1, 97.4 }, // abdomen + new List { -130.0, 8.8 }, + new List { -10.7, 133.7 }, // thorax + new List { -123.9, 10.7 }, + new List { -9.2, 110.1 }, // shoulders + new List { -127.8, 9.2 }, + new List { -9.6, 130.5 }, // head + new List { -120.3, 9.6 }, + new List { -300, -300 }, // arms + new List { -300, -300 }, + }; + List> jawY = new List> + { + new List { -200, 200 }, // pelvis + new List { -200, 200 }, + new List { -200, 200 }, // abdomen + new List { -200, 200 }, + new List { -200, 200 }, // thorax + new List { -200, 200 }, + new List { -200, 200 }, // shoulders + new List { -200, 200 }, + new List { -128.2, 132.4 }, // head + new List { -116.8, 122.1 }, + new List { -163.4, -163.4 }, // arms + new List { -163.4, -163.4 }, + }; + Dictionary>> fieldGeometry = new Dictionary>> + { + { "Isocenters", isocenters }, + { "Jaw_X", jawX }, + { "Jaw_Y", jawY }, + }; + Structure upperPTV = this.upperPlan.StructureSet.Structures.FirstOrDefault(s => s.Id == StructureHelper.UPPER_PTV_NO_JUNCTION); + upperPlan.SetIsocentersUpper(Client.MODEL_NAME_BODY, upperPTV, fieldGeometry); + + try + { + foreach (Beam beam in upperPlan.Beams) + { + if (beam.BeamNumber % 2 != 0) + { + Assert.Equal(179.9, beam.ControlPoints.First().GantryAngle); + Assert.Equal(180.1, beam.ControlPoints.Last().GantryAngle); + Assert.Equal(GantryDirection.CounterClockwise, beam.GantryDirection); + } + else + { + Assert.Equal(180.1, beam.ControlPoints.First().GantryAngle); + Assert.Equal(179.9, beam.ControlPoints.Last().GantryAngle); + Assert.Equal(GantryDirection.Clockwise, beam.GantryDirection); + } + } + } + catch (EqualException e) + { + throw new Exception("Unexpected upper field configuration", e); + } + finally + { + // Teardown + upperPlan.Beams.ToList().ForEach(beam => planInContext.RemoveBeam(beam)); + } + } + + [Fact] + private void SetIsocentersUpperArmsCNN() + { + List> isocenters = new List> + { + new List { 26.0, 118.0, -811.5 }, // pelvis + new List { 26.0, 118.0, -811.5 }, + new List { 26.0, 118.0, -531.6 }, // abdomen + new List { 26.0, 118.0, -531.6 }, + new List { 26.0, 118.0, 163.4 }, // thorax + new List { 26.0, 118.0, 163.4 }, + new List { 26.0, 118.0, -260.6 }, // shoulders + new List { 26.0, 118.0, -260.6 }, + new List { 26.0, 118.0, -16.7 }, // head + new List { 26.0, 118.0, -16.7 }, + new List { -185.5, 118.0, -510.2 }, // arms + new List { 201.9, 118.0, -510.2 }, + }; + List> jawX = new List> + { + new List { -28.7, 153.7 }, // pelvis + new List { -160.3, 13.1 }, + new List { -5.0, 133.6 }, // abdomen + new List { -142.3, 0.0 }, + new List { 26.0, 26.0 }, // thorax + new List { 26.0, 26.0 }, + new List { -10.2, 161.9 }, // shoulders + new List { -143.7, 10.2 }, + new List { -10.9, 119.6 }, // head + new List { -120.8, 10.9 }, + new List { -80.1, 64.3 }, // arms + new List { -75.1, 78.4 }, + }; + List> jawY = new List> + { + new List { -200, 200 }, // pelvis + new List { -200, 200 }, + new List { -200, 200 }, // abdomen + new List { -200, 200 }, + new List { -163.4, -163.4 }, // thorax + new List { -163.4, -163.4 }, + new List { -200, 200 }, // shoulders + new List { -200, 200 }, + new List { -124.0, 119.8 }, // head + new List { -133.8, 133.2 }, + new List { -200.0, 200.0 }, // arms + new List { -200.0, 200.0 }, + }; + Dictionary>> fieldGeometry = new Dictionary>> + { + { "Isocenters", isocenters }, + { "Jaw_X", jawX }, + { "Jaw_Y", jawY }, + }; + Structure upperPTV = this.upperPlan.StructureSet.Structures.FirstOrDefault(s => s.Id == StructureHelper.UPPER_PTV_NO_JUNCTION); + upperPlan.SetIsocentersUpper(Client.MODEL_NAME_ARMS, upperPTV, fieldGeometry); + + try + { + foreach (Beam beam in upperPlan.Beams) + { + if (beam.BeamNumber == 7) // right arm iso + { + Assert.Equal(355.0, beam.ControlPoints.First().CollimatorAngle); + Assert.Equal(179.9, beam.ControlPoints.First().GantryAngle); + Assert.Equal(355.0, beam.ControlPoints.Last().GantryAngle); + Assert.Equal(GantryDirection.CounterClockwise, beam.GantryDirection); + } + else if (beam.BeamNumber == 8) // left arm iso + { + Assert.Equal(5, beam.ControlPoints.First().CollimatorAngle); + Assert.Equal(180.1, beam.ControlPoints.First().GantryAngle); + Assert.Equal(5, beam.ControlPoints.Last().GantryAngle); + Assert.Equal(GantryDirection.Clockwise, beam.GantryDirection); + } + else if (beam.BeamNumber % 2 != 0) + { + Assert.Equal(90, beam.ControlPoints.First().CollimatorAngle); + Assert.Equal(179.9, beam.ControlPoints.First().GantryAngle); + Assert.Equal(180.1, beam.ControlPoints.Last().GantryAngle); + Assert.Equal(GantryDirection.CounterClockwise, beam.GantryDirection); + } + else + { + Assert.Equal(90, beam.ControlPoints.First().CollimatorAngle); + Assert.Equal(180.1, beam.ControlPoints.First().GantryAngle); + Assert.Equal(179.9, beam.ControlPoints.Last().GantryAngle); + Assert.Equal(GantryDirection.Clockwise, beam.GantryDirection); + } + } + } + catch (EqualException e) + { + throw new Exception("Unexpected upper field configuration", e); + } + finally + { + // Teardown + upperPlan.Beams.ToList().ForEach(beam => planInContext.RemoveBeam(beam)); + } + } } } \ No newline at end of file diff --git a/TMIAutomation.Tests/Tests/ModelBaseTests.cs b/TMIAutomation.Tests/Tests/ModelBaseTests.cs index fda6690..13efa2b 100644 --- a/TMIAutomation.Tests/Tests/ModelBaseTests.cs +++ b/TMIAutomation.Tests/Tests/ModelBaseTests.cs @@ -26,9 +26,9 @@ public override ITestBase Init(object testObject, params object[] optParams) private void GetCourses() { #if ESAPI16 - List expectedCourses = new List { "CDemoTest", "CLowerAutoAddOpt", "TEst", "CBaseDoseAddOpt", "CBaseDoseAF", "CBaseDose", "CLowerAuto", "CDemo", "CJunction", "C1" }; + List expectedCourses = new List { "CDemoTest", "CBaseDoseAddOpt", "CBaseDoseAddOpt_", "CLowerAutoAddOpt", "TEst", "CBaseDoseAF", "CBaseDose", "CLowerAuto", "CDemo", "CJunction", "C1" }; #else - List expectedCourses = new List { "CDemoTest", "CScheduling", "CDemo", "LowerAuto", "CJunction", "C1" }; + List expectedCourses = new List { "CDemoTest", "CNoPlan", "CScheduling", "CDemo", "LowerAuto", "CJunction", "C1" }; #endif List courses = modelBase.GetCourses(scriptContext); Assert.Equal(expectedCourses, courses); @@ -54,22 +54,22 @@ public static IEnumerable GetPlans_Data() yield return new object[] { ModelBase.PlanType.Up, 0, "RA_TMLIup5" }; yield return new object[] { ModelBase.PlanType.Up, 1, "RA_TMLIup4" }; yield return new object[] { ModelBase.PlanType.Up, 2, "RA_TMLIup3" }; - yield return new object[] { ModelBase.PlanType.Down, 0, "TMLIdownAuto1" }; - yield return new object[] { ModelBase.PlanType.Down, 1, "TMLIdownAuto" }; + yield return new object[] { ModelBase.PlanType.Down, 0, "TMLIdownAuto" }; + yield return new object[] { ModelBase.PlanType.Down, 1, "TMLIdownAuto1" }; } [Theory] [MemberData(nameof(GetPTVsFromPlan_Data))] - private void GetPTVsFromPlan(string planId, int index, string expectedPlanId) + private void GetPTVsFromPlan(string planId, int index, string expectedPTVId) { List ptvs = modelBase.GetPTVsFromPlan(scriptContext, scriptContext.Course.Id, planId); try { - Assert.Equal(expectedPlanId, ptvs[index]); + Assert.Equal(expectedPTVId, ptvs[index]); } catch (EqualException e) { - throw new Exception($"Input parameters: {planId}, {index}, {expectedPlanId}", e); + throw new Exception($"Input parameters: {planId}, {index}, {expectedPTVId}", e); } } @@ -81,6 +81,7 @@ public static IEnumerable GetPTVsFromPlan_Data() yield return new object[] { "RA_TMLIup3", 1, "PTV_totFIN_Crop" }; yield return new object[] { "TMLIdownAuto", 0, "PTV_Tot_Start" }; yield return new object[] { "TMLIdownAuto", 1, "PTV_Total" }; + yield return new object[] { "", 0, "PTV_Tot_Start" }; } [Theory] @@ -101,7 +102,11 @@ private void GetPTVsFromImgOrientation(PatientOrientation patientOrientation, in public static IEnumerable GetPTVsFromImgOrientation_Data() { yield return new object[] { PatientOrientation.HeadFirstSupine, 0, "PTV_totFIN" }; +#if ESAPI16 yield return new object[] { PatientOrientation.HeadFirstSupine, 1, "UpperPTVNoJ" }; +#else + yield return new object[] { PatientOrientation.HeadFirstSupine, 1, "PTV_totFIN_Crop" }; +#endif yield return new object[] { PatientOrientation.FeetFirstSupine, 0, "PTV_Tot_Start" }; yield return new object[] { PatientOrientation.FeetFirstSupine, 1, "PTV_Total" }; } diff --git a/TMIAutomation/Configuration/DCMExport.txt b/TMIAutomation/Configuration/DCMExport.txt new file mode 100644 index 0000000..aa23cb2 --- /dev/null +++ b/TMIAutomation/Configuration/DCMExport.txt @@ -0,0 +1,12 @@ +# Set to "Automatic" for the automatic export of CT and RTSTRUCT +# Otherwise it is assumed that DICOMs are exported manually and located in the folder Dicoms +ExportType Manual + +# Daemon settings +DaemonAETitle AETitle +DaemonIP 0.0.0.0 +DaemonPort 104 + +# Local machine settings +LocalAETitle AETitle +LocalPort 104 \ No newline at end of file diff --git a/TMIAutomation/Configuration/ESAPI15/OptimizationOptions.txt b/TMIAutomation/Configuration/ESAPI15/OptimizationOptions.txt index 5c2bd8f..8b6cc2c 100644 --- a/TMIAutomation/Configuration/ESAPI15/OptimizationOptions.txt +++ b/TMIAutomation/Configuration/ESAPI15/OptimizationOptions.txt @@ -1,5 +1,9 @@ # Values must be tab separated # Lines starting with a hash sign '#' are ignored # Empty lines are ignored -OptimizationAlgorithm DoseAlgorithm MLCID DosePerFraction NumberOfFractions -PO_15.6.06 AAA 15.06.06 MLC0010 2 1 \ No newline at end of file +OptimizationAlgorithm PO_15.6.06 +DoseAlgorithm AAA 15.06.06 +MLCID MLC0010 +DosePerFraction 2 +NumberOfFractions 1 +TreatmentMachine TrueBeamSN1015 \ No newline at end of file diff --git a/TMIAutomation/Configuration/ESAPI16/OptimizationOptions.txt b/TMIAutomation/Configuration/ESAPI16/OptimizationOptions.txt index e9dcbd2..473efa4 100644 --- a/TMIAutomation/Configuration/ESAPI16/OptimizationOptions.txt +++ b/TMIAutomation/Configuration/ESAPI16/OptimizationOptions.txt @@ -1,5 +1,9 @@ # Values must be tab separated # Lines starting with a hash sign '#' are ignored # Empty lines are ignored -OptimizationAlgorithm DoseAlgorithm MLCID DosePerFraction NumberOfFractions -PO 16.1.0 AAA 15.06.06 MLC0010 2 1 \ No newline at end of file +OptimizationAlgorithm PO 16.1.0 +DoseAlgorithm AAA 15.06.06 +MLCID MLC0010 +DosePerFraction 2 +NumberOfFractions 1 +TreatmentMachine TrueBeamSN1015 \ No newline at end of file diff --git a/TMIAutomation/Configuration/OARNames.txt b/TMIAutomation/Configuration/OARNames.txt new file mode 100644 index 0000000..5c9a109 --- /dev/null +++ b/TMIAutomation/Configuration/OARNames.txt @@ -0,0 +1,12 @@ +# Lines starting with a hash sign '#' are ignored +# Empty lines are ignored +brain +encefalo +lung +polmone +liver +fegato +bowel +intestino +bladder +vescica \ No newline at end of file diff --git a/TMIAutomation/Helper/Configuration/ConfigExport.cs b/TMIAutomation/Helper/Configuration/ConfigExport.cs new file mode 100644 index 0000000..3280893 --- /dev/null +++ b/TMIAutomation/Helper/Configuration/ConfigExport.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using Serilog; + +namespace TMIAutomation +{ + public static class ConfigExport + { + private static readonly ILogger logger = Log.ForContext(typeof(ConfigExport)); + private static readonly string assemblyDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + private static bool isFirstExecution = true; + private static Dictionary exportSettings; + + public static void Init() + { + exportSettings = new Dictionary(); + string exportConfigPath = Path.Combine(assemblyDir, "Configuration", "DCMExport.txt"); + logger.Verbose("Reading DICOM export configuration from {exportConfigPath}", exportConfigPath); + foreach (var line in File.ReadLines(exportConfigPath)) + { + if (line.StartsWith("#") || string.IsNullOrEmpty(line)) continue; + string[] exportConfig = line.Split('\t'); + if (exportConfig.Length == 1) continue; + logger.Verbose("Read parameters: {@exportConfig}", exportConfig); + exportSettings.Add(exportConfig[0], exportConfig[1]); + } + + if (isFirstExecution) + { + DICOMServices.Init(); + DICOMServices.CreateSCP(); + Directory.CreateDirectory(DICOMStorage); + isFirstExecution = false; + } + } + + public static string ExportType => exportSettings["ExportType"]; + public static string DaemonAETitle => exportSettings["DaemonAETitle"]; + public static string DaemonIP => exportSettings["DaemonIP"]; + public static string DaemonPort => exportSettings["DaemonPort"]; + public static string LocalAETitle => exportSettings.ContainsKey("LocalAETitle") ? exportSettings["LocalAETitle"] : Environment.MachineName; + public static string LocalPort => exportSettings["LocalPort"]; + public static string DICOMStorage { get; } = Path.Combine(assemblyDir, "Dicoms"); + } +} diff --git a/TMIAutomation/Helper/Configuration/ConfigOARNames.cs b/TMIAutomation/Helper/Configuration/ConfigOARNames.cs new file mode 100644 index 0000000..b8dddf7 --- /dev/null +++ b/TMIAutomation/Helper/Configuration/ConfigOARNames.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using Serilog; + +namespace TMIAutomation +{ + public static class ConfigOARNames + { + private static readonly ILogger logger = Log.ForContext(typeof(ConfigOARNames)); + private static readonly string assemblyDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + + public static void Init() + { + OarNames = new List(); + string oarNamesPath = Path.Combine(assemblyDir, "Configuration", "OARNames.txt"); + logger.Verbose("Reading OAR names from {oarNamesPath}", oarNamesPath); + foreach (string line in File.ReadLines(oarNamesPath)) + { + if (line.StartsWith("#") || string.IsNullOrEmpty(line)) continue; + logger.Verbose("Read name: {line}", line); + OarNames.Add(line); + } + } + + public static List OarNames { get; private set; } + } +} diff --git a/TMIAutomation/Helper/Configuration/ConfigOptOptions.cs b/TMIAutomation/Helper/Configuration/ConfigOptOptions.cs new file mode 100644 index 0000000..436ccab --- /dev/null +++ b/TMIAutomation/Helper/Configuration/ConfigOptOptions.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using Serilog; + +namespace TMIAutomation +{ + public static class ConfigOptOptions + { + private static readonly ILogger logger = Log.ForContext(typeof(ConfigOptOptions)); + private static readonly string assemblyDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + private static Dictionary optSettings; + + public static void Init() + { + optSettings = new Dictionary(); + string optOptionsPath = Path.Combine(assemblyDir, "Configuration", "OptimizationOptions.txt"); + logger.Verbose("Reading optimization options from {optOptionsPath}", optOptionsPath); + foreach (string line in File.ReadLines(optOptionsPath)) + { + if (line.StartsWith("#") || string.IsNullOrEmpty(line)) continue; + string[] optSetup = line.Split('\t'); + if (optSetup.Length == 1) continue; + logger.Verbose("Read parameters: {@optSetup}", optSetup); + optSettings.Add(optSetup[0], optSetup[1]); + } + } + + public static string OptimizationAlgorithm => optSettings["OptimizationAlgorithm"]; + public static string DoseAlgorithm => optSettings["DoseAlgorithm"]; + public static string MLCID => optSettings["MLCID"]; + public static string DosePerFraction => optSettings["DosePerFraction"]; + public static string NumberOfFractions => optSettings["NumberOfFractions"]; + public static string TreatmentMachine => optSettings["TreatmentMachine"]; + } +} diff --git a/TMIAutomation/Main.cs b/TMIAutomation/Main.cs index 8c40432..8f19b39 100644 --- a/TMIAutomation/Main.cs +++ b/TMIAutomation/Main.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics; using System.IO; using System.Reflection; using System.Runtime.CompilerServices; @@ -9,6 +10,7 @@ using TMIAutomation.View; using TMIAutomation.ViewModel; using VMS.TPS.Common.Model.API; +using VMS.TPS.Common.Model.Types; [assembly: ESAPIScript(IsWriteable = true)] @@ -18,11 +20,13 @@ public class Script { private readonly ILogger logger; private readonly string logPath; + private readonly string executingPath; + private Process serverProcess; public Script() { - string executingPath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); - DirectoryInfo directory = Directory.CreateDirectory(Path.Combine(executingPath, "LOG")); + this.executingPath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + DirectoryInfo directory = Directory.CreateDirectory(Path.Combine(this.executingPath, "LOG")); this.logPath = directory.FullName; Log.Logger = new LoggerConfiguration() @@ -40,6 +44,11 @@ public Script() this.logger = Log.ForContext