qmenta-client 1.1.dev1492__py3-none-any.whl → 1.1.dev1507__py3-none-any.whl

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.
qmenta/client/Project.py CHANGED
@@ -4,14 +4,17 @@ import hashlib
4
4
  import json
5
5
  import logging
6
6
  import os
7
+ import re
7
8
  import sys
8
9
  import time
10
+ from collections import defaultdict
9
11
  from enum import Enum
10
12
 
11
- from qmenta.client import Account
12
13
  from qmenta.core import errors
13
14
  from qmenta.core import platform
14
15
 
16
+ from qmenta.client import Account
17
+
15
18
  if sys.version_info[0] == 3:
16
19
  # Note: this branch & variable is only needed for python 2/3 compatibility
17
20
  unicode = str
@@ -259,14 +262,14 @@ class Project:
259
262
  a power of 2: 2**x. Default value of x is 9 (chunk_size = 512 kB)
260
263
  split_data : bool
261
264
  If True, the platform will try to split the uploaded file into
262
- different sessions. It will be ignored when the ssid is given.
265
+ different sessions. It will be ignored when the ssid or a
266
+ add_to_container_id are given.
263
267
 
264
268
  Returns
265
269
  -------
266
270
  bool
267
271
  True if correctly uploaded, False otherwise.
268
272
  """
269
-
270
273
  filename = os.path.split(file_path)[1]
271
274
  input_data_type = "offline_analysis:1.0" if result else input_data_type
272
275
 
@@ -277,6 +280,8 @@ class Project:
277
280
 
278
281
  total_bytes = os.path.getsize(file_path)
279
282
 
283
+ split_data = self.__assert_split_data(split_data, ssid, add_to_container_id)
284
+
280
285
  # making chunks of the file and sending one by one
281
286
  logger = logging.getLogger(logger_name)
282
287
  with open(file_path, "rb") as file_object:
@@ -293,10 +298,6 @@ class Project:
293
298
  response = None
294
299
  last_chunk = False
295
300
 
296
- if ssid and split_data:
297
- logger.warning("split-data argument will be ignored because" + " ssid has been specified")
298
- split_data = False
299
-
300
301
  while True:
301
302
  data = file_object.read(chunk_size)
302
303
  if not data:
@@ -371,8 +372,7 @@ class Project:
371
372
  logger.error(error)
372
373
  return False
373
374
 
374
- message = "Your data was successfully uploaded."
375
- message += "The uploaded file will be soon processed !"
375
+ message = "Your data was successfully uploaded. The uploaded file will be soon processed !"
376
376
  logger.info(message)
377
377
  return True
378
378
 
@@ -2038,6 +2038,232 @@ class Project:
2038
2038
 
2039
2039
  return res["guidance_text"]
2040
2040
 
2041
+ def parse_qc_text(self, patient_id=None, subject_name=None, ssid=None):
2042
+ """
2043
+ Parse QC (Quality Control) text output into a structured dictionary format.
2044
+
2045
+ This function takes raw QC text output (from the Protocol Adherence analysis)
2046
+ and parses it into a structured format that separates passed and failed rules,
2047
+ along with their associated files and conditions.
2048
+
2049
+ Args:
2050
+ patient_id (str, optional):
2051
+ Patient identifier. Defaults to None.
2052
+ subject_name (str, optional):
2053
+ Subject/patient name. Defaults to None. Mandatory if no patient_id is provided.
2054
+ ssid (str, optional):
2055
+ Session ID. Defaults to None. Mandatory if subject_name is provided.
2056
+
2057
+ Returns:
2058
+ dict: A structured dictionary containing a list of dictionaries with passed rules and their details
2059
+ and failed rules and their details. Details of passed rules are:
2060
+ per each rule: Files that have passed the rule. Per each file name of the file and number of conditions
2061
+ of the rule.
2062
+ Details of failed rules are:
2063
+ - Per each rule failed conditions: Number of times it failed. Each condition status.
2064
+
2065
+ Example:
2066
+ >>> parse_qc_text(subject_name="patient_123", ssid=1)
2067
+ {
2068
+ "passed": [
2069
+ {
2070
+ "rule": "T2",
2071
+ "sub_rule": "rule_15T",
2072
+ "files": [
2073
+ {
2074
+ "file": "path/to/file1",
2075
+ "passed_conditions": 4
2076
+ }
2077
+ ]
2078
+ }
2079
+ ],
2080
+ "failed": [
2081
+ {
2082
+ "rule": "T1",
2083
+ "files": [
2084
+ {
2085
+ "file": "path/to/file2",
2086
+ "conditions": [
2087
+ {
2088
+ "status": "failed",
2089
+ "condition": "SliceThickness between..."
2090
+ }
2091
+ ]
2092
+ }
2093
+ ],
2094
+ "failed_conditions": {
2095
+ "SliceThickness between...": 1
2096
+ }
2097
+ }
2098
+ ]
2099
+ }
2100
+ """
2101
+
2102
+ _, text = self.get_qc_status_subject(patient_id=patient_id, subject_name=subject_name, ssid=ssid)
2103
+
2104
+ result = {"passed": [], "failed": []}
2105
+
2106
+ # Split into failed and passed sections
2107
+ sections = re.split(r"={10,}\n\n", text)
2108
+ if len(sections) == 3:
2109
+ failed_section = sections[1].split("=" * 10)[0].strip()
2110
+ passed_section = sections[2].strip()
2111
+ else:
2112
+ section = sections[1].split("=" * 10)[0].strip()
2113
+ if "PASSED QC MESSAGES" in section:
2114
+ passed_section = section
2115
+ failed_section = ""
2116
+ else:
2117
+ failed_section = section
2118
+ passed_section = ""
2119
+
2120
+ # Parse failed rules
2121
+ failed_rules = re.split(r"\n ❌ ", failed_section)
2122
+ result = self.__parse_fail_rules(failed_rules, result)
2123
+
2124
+ # Parse passed rules
2125
+ passed_rules = re.split(r"\n ✅ ", passed_section)
2126
+ result = self.__parse_pass_rules(passed_rules, result)
2127
+
2128
+ return result
2129
+
2130
+ def calculate_qc_statistics(self):
2131
+ """
2132
+ Calculate comprehensive statistics from multiple QC results across subjects from a project in the QMENTA
2133
+ platform.
2134
+
2135
+ This function aggregates and analyzes QC results from multiple subjects/containers,
2136
+ providing statistical insights about rule pass/fail rates, file statistics,
2137
+ and condition failure patterns.
2138
+
2139
+ Returns:
2140
+ dict: A dictionary containing comprehensive QC statistics including:
2141
+ - passed_rules: Total count of passed rules across all subjects
2142
+ - failed_rules: Total count of failed rules across all subjects
2143
+ - subjects_passed: Count of subjects with no failed rules
2144
+ - subjects_with_failed: Count of subjects with at least one failed rule
2145
+ - num_passed_files_distribution: Distribution of how many rules have N passed files
2146
+ - file_stats: File-level statistics (total, passed, failed, pass percentage)
2147
+ - condition_failure_rates: Frequency and percentage of each failed condition
2148
+ - rule_success_rates: Success rates for each rule type
2149
+
2150
+ The statistics help identify:
2151
+ - Overall QC pass rates
2152
+ - Most common failure conditions
2153
+ - Rule-specific success rates
2154
+ - Distribution of passed files per rule
2155
+ - Subject-level pass rates
2156
+
2157
+ Example:
2158
+ >>> project.calculate_qc_statistics()
2159
+ {
2160
+ "passed_rules": 42,
2161
+ "failed_rules": 8,
2162
+ "subjects_passed": 15,
2163
+ "subjects_with_failed": 5,
2164
+ "num_passed_files_distribution": {
2165
+ "1": 30,
2166
+ "2": 12
2167
+ },
2168
+ "file_stats": {
2169
+ "total": 50,
2170
+ "passed": 45,
2171
+ "failed": 5,
2172
+ "pass_percentage": 90.0
2173
+ },
2174
+ "condition_failure_rates": {
2175
+ "SliceThickness": {
2176
+ "count": 5,
2177
+ "percentage": 62.5
2178
+ }
2179
+ },
2180
+ "rule_success_rates": {
2181
+ "T1": {
2182
+ "passed": 20,
2183
+ "failed": 2,
2184
+ "success_rate": 90.91
2185
+ }
2186
+ }
2187
+ }
2188
+ """
2189
+ qc_results_list = list()
2190
+ containers = self.list_input_containers()
2191
+
2192
+ for c in containers:
2193
+ qc_results_list.append(self.parse_qc_text(subject_name=c["patient_secret_name"], ssid=c["ssid"]))
2194
+
2195
+ # Initialize statistics
2196
+ stats = {
2197
+ "passed_rules": 0,
2198
+ "failed_rules": 0,
2199
+ "subjects_passed": 0,
2200
+ "subjects_with_failed": 0,
2201
+ "num_passed_files_distribution": defaultdict(int), # How many rules have N passed files
2202
+ "file_stats": {"total": 0, "passed": 0, "failed": 0, "pass_percentage": 0.0},
2203
+ "condition_failure_rates": defaultdict(lambda: {"count": 0, "percentage": 0.0}),
2204
+ "rule_success_rates": defaultdict(lambda: {"passed": 0, "failed": 0, "success_rate": 0.0}),
2205
+ }
2206
+
2207
+ total_failures = 0
2208
+
2209
+ # sum subjects with not failed qc message
2210
+ stats["subjects_passed"] = sum([1 for rules in qc_results_list if not rules["failed"]])
2211
+ # sum subjects with some failed qc message
2212
+ stats["subjects_with_failed"] = sum([1 for rules in qc_results_list if rules["failed"]])
2213
+ # sum rules that have passed
2214
+ stats["passed_rules"] = sum([len(rules["passed"]) for rules in qc_results_list if rules["failed"]])
2215
+ # sum rules that have failed
2216
+ stats["failed_rules"] = sum([len(rules["failed"]) for rules in qc_results_list if rules["failed"]])
2217
+
2218
+ for qc_results in qc_results_list:
2219
+
2220
+ # Count passed files distribution
2221
+ for rule in qc_results["passed"]:
2222
+ num_files = len(rule["files"])
2223
+ stats["num_passed_files_distribution"][num_files] += 1
2224
+ stats["file_stats"]["passed"] += len(rule["files"])
2225
+ stats["file_stats"]["total"] += len(rule["files"])
2226
+ rule_name = rule["rule"]
2227
+ stats["rule_success_rates"][rule_name]["passed"] += 1
2228
+
2229
+ for rule in qc_results["failed"]:
2230
+ stats["file_stats"]["total"] += len(rule["files"])
2231
+ stats["file_stats"]["failed"] += len(rule["files"])
2232
+ for condition, count in rule["failed_conditions"].items():
2233
+ # Extract just the condition text without actual value
2234
+ clean_condition = re.sub(r"\.\s*Actual value:.*$", "", condition)
2235
+ stats["condition_failure_rates"][clean_condition]["count"] += count
2236
+ total_failures += count
2237
+ rule_name = rule["rule"]
2238
+ stats["rule_success_rates"][rule_name]["failed"] += 1
2239
+
2240
+ if stats["file_stats"]["total"] > 0:
2241
+ stats["file_stats"]["pass_percentage"] = round(
2242
+ (stats["file_stats"]["passed"] / stats["file_stats"]["total"]) * 100, 2
2243
+ )
2244
+
2245
+ # Calculate condition failure percentages
2246
+ for condition in stats["condition_failure_rates"]:
2247
+ if total_failures > 0:
2248
+ stats["condition_failure_rates"][condition]["percentage"] = round(
2249
+ (stats["condition_failure_rates"][condition]["count"] / total_failures) * 100, 2
2250
+ )
2251
+
2252
+ # Calculate rule success rates
2253
+ for rule in stats["rule_success_rates"]:
2254
+ total = stats["rule_success_rates"][rule]["passed"] + stats["rule_success_rates"][rule]["failed"]
2255
+ if total > 0:
2256
+ stats["rule_success_rates"][rule]["success_rate"] = round(
2257
+ (stats["rule_success_rates"][rule]["passed"] / total) * 100, 2
2258
+ )
2259
+
2260
+ # Convert defaultdict to regular dict for cleaner JSON output
2261
+ stats["num_passed_files_distribution"] = dict(stats["num_passed_files_distribution"])
2262
+ stats["condition_failure_rates"] = dict(stats["condition_failure_rates"])
2263
+ stats["rule_success_rates"] = dict(stats["rule_success_rates"])
2264
+
2265
+ return stats
2266
+
2041
2267
  """ Helper Methods """
2042
2268
 
2043
2269
  def __handle_start_analysis(self, post_data, ignore_warnings=False, ignore_file_selection=True, n_calls=0):
@@ -2143,7 +2369,6 @@ class Project:
2143
2369
  elif post_data.get("cancel"):
2144
2370
  continue
2145
2371
 
2146
- number_of_files_to_select = 1
2147
2372
  if filter_data["range"][0] != 0:
2148
2373
  number_of_files_to_select = filter_data["range"][0]
2149
2374
  elif filter_data["range"][1] != 0:
@@ -2162,7 +2387,7 @@ class Project:
2162
2387
  logger.warning(
2163
2388
  f" · File filter name: '{filter_key}'. Type "
2164
2389
  f"{number_of_files_to_select} file"
2165
- f"{'s (i.e., file1.zip, file2.zip, file3.zip)' if number_of_files_to_select >1 else ''}."
2390
+ f"{'s (i.e., file1.zip, file2.zip, file3.zip)' if number_of_files_to_select > 1 else ''}."
2166
2391
  )
2167
2392
  save_file_ids, select_file_filter = {}, ""
2168
2393
  for file_ in filter_data["files"]:
@@ -2365,3 +2590,107 @@ class Project:
2365
2590
  value.replace(d_type + ";", "")
2366
2591
  file_metadata[d_tag] = {"operation": "in-list", "value": value.replace(d_type + ";", "").split(";")}
2367
2592
  return modality, tags, file_metadata
2593
+
2594
+ def __assert_split_data(self, split_data, ssid, add_to_container_id):
2595
+ """
2596
+ Assert if the split_data parameter is possible to use in regards
2597
+ to the ssid and add_to_container_id parameters during upload.
2598
+ Changes its status to False if needed.
2599
+
2600
+ Parameters
2601
+ ----------
2602
+ split_data : Bool
2603
+ split_data parameter from method 'upload_file'.
2604
+ ssid : str
2605
+ Session ID.
2606
+ add_to_container_id : int or bool
2607
+ Container ID or False
2608
+
2609
+ Returns
2610
+ -------
2611
+ split_data : Bool
2612
+
2613
+ """
2614
+
2615
+ logger = logging.getLogger(logger_name)
2616
+ if ssid and split_data:
2617
+ logger.warning("split-data argument will be ignored because ssid has been specified")
2618
+ split_data = False
2619
+
2620
+ if add_to_container_id and split_data:
2621
+ logger.warning("split-data argument will be ignored because add_to_container_id has been specified")
2622
+ split_data = False
2623
+
2624
+ return split_data
2625
+
2626
+ def __parse_fail_rules(self, failed_rules, result):
2627
+ """
2628
+ Parse fail rules.
2629
+ """
2630
+
2631
+ for rule_text in failed_rules[1:]: # Skip first empty part
2632
+ rule_name = rule_text.split(" ❌")[0].strip()
2633
+ rule_data = {"rule": rule_name, "files": [], "failed_conditions": {}}
2634
+
2635
+ # Extract all file comparisons for this rule
2636
+ file_comparisons = re.split(r"\t- Comparison with file:", rule_text)
2637
+ for comp in file_comparisons[1:]: # Skip first part
2638
+ file_name = comp.split("\n")[0].strip()
2639
+ conditions_match = re.search(r"Conditions:(.*?)(?=\n\t- Comparison|\n\n|$)", comp, re.DOTALL)
2640
+ if not conditions_match:
2641
+ continue
2642
+
2643
+ conditions_text = conditions_match.group(1).strip()
2644
+ # Parse conditions
2645
+ conditions = []
2646
+ for line in conditions_text.split("\n"):
2647
+ line = line.strip()
2648
+ if line.startswith("·"):
2649
+ status = "✔" if "✔" in line else "🚫"
2650
+ condition = re.sub(r"^· [✔🚫]\s*", "", line)
2651
+ conditions.append({"status": "passed" if status == "✔" else "failed", "condition": condition})
2652
+
2653
+ # Add to failed conditions summary
2654
+ for cond in conditions:
2655
+ if cond["status"] == "failed":
2656
+ cond_text = cond["condition"]
2657
+ if cond_text not in rule_data["failed_conditions"]:
2658
+ rule_data["failed_conditions"][cond_text] = 0
2659
+ rule_data["failed_conditions"][cond_text] += 1
2660
+
2661
+ rule_data["files"].append({"file": file_name, "conditions": conditions})
2662
+
2663
+ result["failed"].append(rule_data)
2664
+ return result
2665
+
2666
+ def __parse_pass_rules(self, passed_rules, result):
2667
+ """
2668
+ Parse pass rules.
2669
+ """
2670
+
2671
+ for rule_text in passed_rules[1:]: # Skip first empty part
2672
+ rule_name = rule_text.split(" ✅")[0].strip()
2673
+ rule_data = {"rule": rule_name, "sub_rule": None, "files": []}
2674
+
2675
+ # Get sub-rule
2676
+ sub_rule_match = re.search(r"Sub-rule: (.*?)\n", rule_text)
2677
+ if sub_rule_match:
2678
+ rule_data["sub_rule"] = sub_rule_match.group(1).strip()
2679
+
2680
+ # Get files passed
2681
+ files_passed = re.search(r"List of files passed:(.*?)(?=\n\n|\Z)", rule_text, re.DOTALL)
2682
+ if files_passed:
2683
+ for line in files_passed.group(1).split("\n"):
2684
+ line = line.strip()
2685
+ if line.startswith("·"):
2686
+ file_match = re.match(r"· (.*?) \((\d+)/(\d+)\)", line)
2687
+ if file_match:
2688
+ rule_data["files"].append(
2689
+ {
2690
+ "file": file_match.group(1).strip(),
2691
+ "passed_conditions": int(file_match.group(2)),
2692
+ }
2693
+ )
2694
+
2695
+ result["passed"].append(rule_data)
2696
+ return result
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: qmenta-client
3
- Version: 1.1.dev1492
3
+ Version: 1.1.dev1507
4
4
  Summary: Python client lib to interact with the QMENTA platform.
5
5
  Author: QMENTA
6
6
  Author-email: dev@qmenta.com
@@ -1,10 +1,10 @@
1
1
  qmenta/__init__.py,sha256=ED6jHcYiuYpr_0vjGz0zx2lrrmJT9sDJCzIljoDfmlM,65
2
2
  qmenta/client/Account.py,sha256=7BOWHtRbHdfpBYQqv9v2m2Fag13pExZSxFsjDA7UsW0,9500
3
3
  qmenta/client/File.py,sha256=iCrzrd7rIfjjW2AgMgUoK-ZF2wf-95wCcPKxKw6PGyg,4816
4
- qmenta/client/Project.py,sha256=JQZc5BMWSdPEMGBp1gsZFbhjSbFg-0AoBJFXakddojw,86466
4
+ qmenta/client/Project.py,sha256=pV9mW90BzPIMOFauPiAONBIsYJfGsbi_Xabbe9DW32U,100493
5
5
  qmenta/client/Subject.py,sha256=b5sg9UFtn11bmPM-xFXP8aehOm_HGxnhgT7IPKbrZnE,8688
6
6
  qmenta/client/__init__.py,sha256=Mtqe4zf8n3wuwMXSALENQgp5atQY5VcsyXWs2hjBs28,133
7
7
  qmenta/client/utils.py,sha256=vWUAW0r9yDetdlwNo86sdzKn03FNGvwa7D9UtOA3TEc,2419
8
- qmenta_client-1.1.dev1492.dist-info/METADATA,sha256=SSKZb9aDxY8X5qXEwBYyrDBLpU765M_7Z3zOJWC7UdM,672
9
- qmenta_client-1.1.dev1492.dist-info/WHEEL,sha256=XbeZDeTWKc1w7CSIyre5aMDU_-PohRwTQceYnisIYYY,88
10
- qmenta_client-1.1.dev1492.dist-info/RECORD,,
8
+ qmenta_client-1.1.dev1507.dist-info/METADATA,sha256=hGQLiIggQT0NX8tOF8VIklAu9VdsOSldflacTNBIo9g,672
9
+ qmenta_client-1.1.dev1507.dist-info/WHEEL,sha256=XbeZDeTWKc1w7CSIyre5aMDU_-PohRwTQceYnisIYYY,88
10
+ qmenta_client-1.1.dev1507.dist-info/RECORD,,