├── .editorconfig ├── .gitattributes ├── .github └── workflows │ ├── docker.yaml │ └── test.yaml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── Makefile ├── README.md ├── _version.py ├── docker └── Dockerfile ├── example └── example.scp ├── leadtable.csv ├── leadtable.py ├── scp.py ├── scpformat.py ├── scpinfo.py ├── scpreader.py ├── scputil.py ├── test_scpinfo.py └── test_scputil.py /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 4 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | 12 | [*.md] 13 | max_line_length = off 14 | trim_trailing_whitespace = false 15 | 16 | [*.json] 17 | indent_size = 2 18 | 19 | [*.py] 20 | indent_style = space 21 | indent_size = 4 22 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | 3 | # batch files 4 | 5 | *.sh text eol=lf 6 | *.bat text eol=crlf 7 | 8 | # sensible defaults taken from https://github.com/alexkaratarakis/gitattributes 9 | 10 | # Documents 11 | 12 | *.md text 13 | *.adoc text 14 | *.csv text 15 | *.py text 16 | Makefile text eol=lf 17 | Dockerfile text eol=lf 18 | 19 | # Graphics 20 | 21 | *.png binary 22 | *.jpg binary 23 | *.jpeg binary 24 | *.gif binary 25 | 26 | # Program files / Libraries 27 | 28 | *.json text 29 | *.properties text 30 | *.tld text 31 | *.txt text 32 | *.xml text 33 | 34 | -------------------------------------------------------------------------------- /.github/workflows/docker.yaml: -------------------------------------------------------------------------------- 1 | name: Docker build 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: Docker Linter 14 | uses: hadolint/hadolint-action@v3.0.0 15 | with: 16 | dockerfile: docker/Dockerfile 17 | no-fail: false 18 | failure-threshold: error 19 | verbose: true 20 | - name: Login to Docker Hub 21 | uses: docker/login-action@v2 22 | with: 23 | username: ${{ secrets.DOCKER_HUB_USERNAME }} 24 | password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} 25 | - name: Set up Docker Buildx 26 | uses: docker/setup-buildx-action@v2 27 | - name: Build and push docker image 28 | uses: docker/build-push-action@v3 29 | with: 30 | context: . 31 | file: docker/Dockerfile 32 | push: true 33 | tags: ${{ secrets.DOCKER_HUB_USERNAME }}/scpinfo:latest -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Python test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python-version: ["3.8", "3.9", "3.13.0"] 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v4 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | pip install flake8 pytest 23 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 24 | - name: Lint with flake8 25 | run: | 26 | # stop the build if there are Python syntax errors or undefined names 27 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 28 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 29 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 30 | - name: Test with pytest 31 | run: pytest 32 | 33 | build: 34 | 35 | runs-on: ubuntu-latest 36 | steps: 37 | - uses: actions/checkout@v3 38 | - name: Docker Linter 39 | uses: hadolint/hadolint-action@v3.0.0 40 | with: 41 | dockerfile: docker/Dockerfile 42 | no-fail: false 43 | failure-threshold: error 44 | verbose: true 45 | - name: Login to Docker Hub 46 | uses: docker/login-action@v2 47 | with: 48 | username: ${{ secrets.DOCKER_HUB_USERNAME }} 49 | password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} 50 | - name: Set up Docker Buildx 51 | uses: docker/setup-buildx-action@v2 52 | - name: Build and push docker image 53 | uses: docker/build-push-action@v3 54 | with: 55 | context: . 56 | file: docker/Dockerfile 57 | push: true 58 | tags: ${{ secrets.DOCKER_HUB_USERNAME }}/scpinfo:latest -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | __pycache__ 3 | .pytest_cache -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.formatting.provider": "black" 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 gitrust 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | info: 3 | python scpinfo.py example/example.scp 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Description 2 | 3 | A python command line tool to read an SCP-ECG (https://en.wikipedia.org/wiki/SCP-ECG) file and print structure information about ECG traces and metadata. 4 | 5 | # SCP-ECG Support 6 | 7 | Currently following SCP sections can be read with this tool 8 | 9 | - Section0 10 | - Section1 11 | - Section2 (partly) 12 | - Section3 13 | - Section5 (partly) 14 | - Section6 (partly) 15 | - Section7 (partly) 16 | - Section8 (partly) 17 | 18 | Section2 (Huffman encoding) is not interpreted currently, so if Section2 is available Section5 and Section6 samples would need recalculation using Huffman tables from Section2. 19 | 20 | # Command line usage 21 | 22 | ``` 23 | usage: scpinfo.py [-h] [--csv section_id] scpfile 24 | 25 | positional arguments: 26 | scpfile input SCP file 27 | 28 | optional arguments: 29 | -h, --help show this help message and exit 30 | --csv section_id print leads in CSV format for section 5 or 6, specify here 31 | a section id 32 | ``` 33 | 34 | # Docker 35 | 36 | There is a python based Docker image [gitrust/scpinfo](https://hub.docker.com/r/gitrust/scpinfo) to run scpinfo. 37 | 38 | Run examples 39 | 40 | ``` 41 | docker run --rm -it -v $(pwd):/home/docker gitrust/scpinfo example.scp 42 | 43 | docker run --rm -it -v $(pwd):/home/docker gitrust/scpinfo --csv 5 example.scp 44 | 45 | # under Windows 46 | docker run --rm -it -v %cd%:/home/docker gitrust/scpinfo example.scp 47 | 48 | ``` 49 | 50 | On Windows OS use `%cd%` instead of `$(pwd)` for volume binding. 51 | 52 | 53 | # Example usage 54 | 55 | ``` 56 | python scpinfo.py example/example.scp 57 | ``` 58 | 59 | ``` 60 | --ScpRec-- ---- 61 | CRC 1643 62 | RecLen 34144 63 | --Section0-- ---- 64 | --SectionHeader-- ---- 65 | CRC 21978 66 | Id: 0 67 | Length 136 68 | VersionNr 20 69 | ProtocolNr 20 70 | Reserved SCPECG 71 | ---- ---- 72 | Pointer Count 12 73 | Pointers 0(136), 1(168), 2(18), 3(126), 4(22), 5(3342), 6(30084), 7(242), 8(0), 9(0), 10(0), 11(0) 74 | 75 | --Section1-- ---- 76 | --SectionHeader-- ---- 77 | CRC 24375 78 | Id: 1 79 | Length 168 80 | VersionNr 20 81 | ProtocolNr 20 82 | Reserved 83 | ---- ---- 84 | Res 85 | Tags 12 86 | LastName Clark 87 | FirstName 88 | Pat Id SBJ-123 89 | Second LastName 90 | Age 91 | DateOfBirth 1953/5/8 92 | Height 93 | Weight 94 | Sex Male 95 | Race Caucasian 96 | Sys (mmHg) 97 | Dia (mmHg) 98 | Acq. Device MachineID ---- 99 | InstNr 0 100 | DepNr 11 101 | DevId 0 102 | DevType 0 103 | Model LI250 104 | ---- 105 | Acq. Institution Desc 106 | Acq. Institution Desc 107 | Acq. Department Desc 108 | Anal. Department Desc 109 | Referring Physician 110 | Latest Confirm. Phys 111 | Technician Physician 112 | Room Description 113 | Stat Code 114 | Date of Acquis. 2002/11/22 115 | Time of Acquis. 09:10:00 116 | Baseline Filter 0 117 | LowPass Filter 0 118 | Other Filters 119 | Ecg Seq. 120 | 121 | --Section2-- ---- 122 | --SectionHeader-- ---- 123 | CRC 22179 124 | Id: 2 125 | Length 18 126 | VersionNr 20 127 | ProtocolNr 20 128 | Reserved 129 | ---- ---- 130 | 131 | --Section3-- ---- 132 | --SectionHeader-- ---- 133 | CRC -19898 134 | Id: 3 135 | Length 126 136 | VersionNr 20 137 | ProtocolNr 20 138 | Reserved 139 | ---- ---- 140 | RefBeatSet False 141 | Sim-rec Leads 12 142 | LeadCount 12 143 | Leads I, II, V1, V2, V3, V4, V5, V6, III, aVR, aVL, aVF 144 | SampleCount I (5000), II (5000), V1 (5000), V2 (5000), V3 (5000), V4 (5000), V5 (5000), V6 (5000), III (5000), aVR (5000), aVL (5000), aVF (5000) 145 | 146 | --Section4-- ---- 147 | --SectionHeader-- ---- 148 | CRC 4777 149 | Id: 4 150 | Length 22 151 | VersionNr 20 152 | ProtocolNr 20 153 | Reserved 154 | ---- ---- 155 | 156 | --Section5-- ---- 157 | --SectionHeader-- ---- 158 | CRC -22034 159 | Id: 5 160 | Length 3342 161 | VersionNr 20 162 | ProtocolNr 20 163 | Reserved 164 | ---- ---- 165 | AVM (nV) 2500 166 | SampleTime (µs) 2000 167 | Sample Encoding Second difference 168 | RefBeat 0, Bytes 272, 270, 258, 282, 266, 270, 260, 276, 322, 242, 288, 290 169 | 170 | --Section6-- ---- 171 | --SectionHeader-- ---- 172 | CRC -4046 173 | Id: 6 174 | Length 30084 175 | VersionNr 20 176 | ProtocolNr 20 177 | Reserved 178 | ---- ---- 179 | AVM (nV) 2500 180 | SampleTime (µs) 2000 181 | Sample Encoding Second difference 182 | Bimodal compression False 183 | Lead Samples, Bytes 2510, 2426, 2354, 2468, 2412, 2356, 2450, 2634, 3002, 2162, 2668, 2596 184 | 185 | --Section7-- ---- 186 | --SectionHeader-- ---- 187 | CRC 26535 188 | Id: 7 189 | Length 242 190 | VersionNr 20 191 | ProtocolNr 20 192 | Reserved 193 | ---- ---- 194 | ``` 195 | 196 | # Example CSV usage 197 | 198 | ``` 199 | python scpinfo.py --csv 6 example/example.scp | head 200 | ``` 201 | 202 | 203 | ``` 204 | I II V1 V2 V3 V4 V5 V6 III aVR aVL aVF 205 | 11229 -2 -29953 -29185 -29953 -30721 -31233 -16897 -2821 -15629 -20080 -515 206 | -27845 28480 -7425 -7169 -7617 -7873 -7681 -23553 -6515 -22956 -4764 19237 207 | -27977 13979 25265 -17327 -4222 -9791 -14734 7543 -1545 9590 18382 -13345 208 | -8387 -6161 -26641 -28022 -14789 22981 -5134 26312 -17545 -9242 -1571 30587 209 | -19694 4974 15163 -8985 -18067 -9177 1591 -10096 -3905 11541 26469 -8452 210 | -25271 22905 23490 -6514 15955 -17305 7029 -18795 -1195 -27291 -26267 15329 211 | -9838 30646 -14314 -3722 15843 1775 -28789 -16525 20374 21798 28334 19941 212 | 19666 28307 19422 -27796 26564 14199 10617 -8402 21630 22238 29415 -7109 213 | 13033 22946 19062 14947 -26199 21013 15219 -2101 7927 25326 -25143 -11028 214 | ``` 215 | -------------------------------------------------------------------------------- /_version.py: -------------------------------------------------------------------------------- 1 | __version__ = '2020.01' 2 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11.1-alpine3.17 2 | 3 | RUN mkdir -p /src 4 | WORKDIR /src 5 | 6 | COPY *.py /src/ 7 | COPY leadtable.csv /src/ 8 | 9 | RUN adduser -D docker 10 | USER docker 11 | 12 | WORKDIR /home/docker 13 | 14 | ENTRYPOINT ["python", "/src/scpinfo.py"] 15 | CMD ["--help"] 16 | -------------------------------------------------------------------------------- /example/example.scp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitrust/scpinfo/9b8293800b84b74754315d132d369967ab28ffb6/example/example.scp -------------------------------------------------------------------------------- /leadtable.csv: -------------------------------------------------------------------------------- 1 | 0,0 2 | 1,I 3 | 2,II 4 | 3,V1 5 | 4,V2 6 | 5,V3 7 | 6,V4 8 | 7,V5 9 | 8,V6 10 | 9,V7 11 | 10,V2R 12 | 11,V3R 13 | 12,V4R 14 | 13,V5R 15 | 14,V6R 16 | 15,V7R 17 | 16,X 18 | 17,Y 19 | 18,Z 20 | 19,CC5 21 | 20,CM5 22 | 21,LA 23 | 22,RA 24 | 23,LL 25 | 24,fl 26 | 25,fE 27 | 26,fC 28 | 27,fA 29 | 28,fM 30 | 29,fF 31 | 30,fH 32 | 31,dI 33 | 32,dII 34 | 33,dV1 35 | 34,dV2 36 | 35,dV3 37 | 36,dV4 38 | 37,dV5 39 | 38,dV6 40 | 39,dV7 41 | 40,dV2R 42 | 41,dV3R 43 | 42,dV4R 44 | 43,dV5R 45 | 44,dV6R 46 | 45,dV7R 47 | 46,dX 48 | 47,dY 49 | 48,dZ 50 | 49,dCC5 51 | 50,dCM5 52 | 51,dLA 53 | 52,dRA 54 | 53,dLL 55 | 54,dfI 56 | 55,dfE 57 | 56,dfC 58 | 57,dfA 59 | 58,dfM 60 | 59,dfF 61 | 60,dfH 62 | 61,III 63 | 62,aVR 64 | 63,aVL 65 | 64,aVF 66 | 65,aVRneg 67 | 66,V8 68 | 67,V9 69 | 68,V8R 70 | 69,V9R 71 | 70,D 72 | 71,A 73 | 72,J 74 | 73,Defib 75 | 74,Extern 76 | 75,A1 77 | 76,A2 78 | 77,A3 79 | 78,A4 80 | 79,dV8 81 | 80,dV9 82 | 81,dV8R 83 | 82,dV9R 84 | 83,dD 85 | 84,dA 86 | 85,dJ 87 | 86,Chest 88 | 87,V 89 | 88,VR 90 | 89,VL 91 | 90,VF 92 | 91,MCL 93 | 92,MCL1 94 | 93,MCL2 95 | 94,MCL3 96 | 95,MCL4 97 | 96,MCL5 98 | 97,MCL6 99 | 98,CC 100 | 99,CC1 101 | 100,CC2 102 | 101,CC3 103 | 102,CC4 104 | 103,CC6 105 | 104,CC7 106 | 105,CM 107 | 106,CM1 108 | 107,CM2 109 | 108,CM3 110 | 109,CM4 111 | 110,CM6 112 | 111,dIII 113 | 112,daVR 114 | 113,daVL 115 | 114,daVF 116 | 115,daVRneg 117 | 116,dChest 118 | 117,dV 119 | 118,dVR 120 | 119,dVL 121 | 120,dVF 122 | 121,CM7 123 | 122,CH5 124 | 123,CS5 125 | 124,CB5 126 | 125,CR5 127 | 126,ML 128 | 127,AB1 129 | 128,AB2 130 | 129,AB3 131 | 130,AB4 132 | 131,ES 133 | 132,AS 134 | 133,AI 135 | 134,S 136 | 135,dDefib 137 | 136,dExtern 138 | 137,dA1 139 | 138,dA2 140 | 139,dA3 141 | 140,dA4 142 | 141,dMCL1 143 | 142,dMCL2 144 | 143,dMCL3 145 | 144,dMCL4 146 | 145,dMCL5 147 | 146,dMCL6 148 | 147,RL 149 | 148,CV5RL 150 | 149,CV6LL 151 | 150,CV6LU 152 | 151,V10 153 | 152,dMCL 154 | 153,dCC 155 | 154,dCC1 156 | 155,dCC2 157 | 156,dCC3 158 | 157,dCC4 159 | 158,dCC6 160 | 159,dCC7 161 | 160,dCM 162 | 161,dCM1 163 | 162,dCM2 164 | 163,dCM3 165 | 164,dCM4 166 | 165,dCM6 167 | 166,dCM7 168 | 167,dCH5 169 | 168,dCS5 170 | 169,dCB5 171 | 170,dCR5 172 | 171,dML 173 | 172,dAB1 174 | 173,dAB2 175 | 174,dAB3 176 | 175,dAB4 177 | 176,dES 178 | 177,dAS 179 | 178,dAI 180 | 179,dS 181 | 180,dRL 182 | 181,dCV5RL 183 | 182,dCV6LL 184 | 183,dCV6LU 185 | 184,dV10 -------------------------------------------------------------------------------- /leadtable.py: -------------------------------------------------------------------------------- 1 | 2 | lead_table="""0,0 3 | 1,I 4 | 2,II 5 | 3,V1 6 | 4,V2 7 | 5,V3 8 | 6,V4 9 | 7,V5 10 | 8,V6 11 | 9,V7 12 | 10,V2R 13 | 11,V3R 14 | 12,V4R 15 | 13,V5R 16 | 14,V6R 17 | 15,V7R 18 | 16,X 19 | 17,Y 20 | 18,Z 21 | 19,CC5 22 | 20,CM5 23 | 21,LA 24 | 22,RA 25 | 23,LL 26 | 24,fl 27 | 25,fE 28 | 26,fC 29 | 27,fA 30 | 28,fM 31 | 29,fF 32 | 30,fH 33 | 31,dI 34 | 32,dII 35 | 33,dV1 36 | 34,dV2 37 | 35,dV3 38 | 36,dV4 39 | 37,dV5 40 | 38,dV6 41 | 39,dV7 42 | 40,dV2R 43 | 41,dV3R 44 | 42,dV4R 45 | 43,dV5R 46 | 44,dV6R 47 | 45,dV7R 48 | 46,dX 49 | 47,dY 50 | 48,dZ 51 | 49,dCC5 52 | 50,dCM5 53 | 51,dLA 54 | 52,dRA 55 | 53,dLL 56 | 54,dfI 57 | 55,dfE 58 | 56,dfC 59 | 57,dfA 60 | 58,dfM 61 | 59,dfF 62 | 60,dfH 63 | 61,III 64 | 62,aVR 65 | 63,aVL 66 | 64,aVF 67 | 65,aVRneg 68 | 66,V8 69 | 67,V9 70 | 68,V8R 71 | 69,V9R 72 | 70,D 73 | 71,A 74 | 72,J 75 | 73,Defib 76 | 74,Extern 77 | 75,A1 78 | 76,A2 79 | 77,A3 80 | 78,A4 81 | 79,dV8 82 | 80,dV9 83 | 81,dV8R 84 | 82,dV9R 85 | 83,dD 86 | 84,dA 87 | 85,dJ 88 | 86,Chest 89 | 87,V 90 | 88,VR 91 | 89,VL 92 | 90,VF 93 | 91,MCL 94 | 92,MCL1 95 | 93,MCL2 96 | 94,MCL3 97 | 95,MCL4 98 | 96,MCL5 99 | 97,MCL6 100 | 98,CC 101 | 99,CC1 102 | 100,CC2 103 | 101,CC3 104 | 102,CC4 105 | 103,CC6 106 | 104,CC7 107 | 105,CM 108 | 106,CM1 109 | 107,CM2 110 | 108,CM3 111 | 109,CM4 112 | 110,CM6 113 | 111,dIII 114 | 112,daVR 115 | 113,daVL 116 | 114,daVF 117 | 115,daVRneg 118 | 116,dChest 119 | 117,dV 120 | 118,dVR 121 | 119,dVL 122 | 120,dVF 123 | 121,CM7 124 | 122,CH5 125 | 123,CS5 126 | 124,CB5 127 | 125,CR5 128 | 126,ML 129 | 127,AB1 130 | 128,AB2 131 | 129,AB3 132 | 130,AB4 133 | 131,ES 134 | 132,AS 135 | 133,AI 136 | 134,S 137 | 135,dDefib 138 | 136,dExtern 139 | 137,dA1 140 | 138,dA2 141 | 139,dA3 142 | 140,dA4 143 | 141,dMCL1 144 | 142,dMCL2 145 | 143,dMCL3 146 | 144,dMCL4 147 | 145,dMCL5 148 | 146,dMCL6 149 | 147,RL 150 | 148,CV5RL 151 | 149,CV6LL 152 | 150,CV6LU 153 | 151,V10 154 | 152,dMCL 155 | 153,dCC 156 | 154,dCC1 157 | 155,dCC2 158 | 156,dCC3 159 | 157,dCC4 160 | 158,dCC6 161 | 159,dCC7 162 | 160,dCM 163 | 161,dCM1 164 | 162,dCM2 165 | 163,dCM3 166 | 164,dCM4 167 | 165,dCM6 168 | 166,dCM7 169 | 167,dCH5 170 | 168,dCS5 171 | 169,dCB5 172 | 170,dCR5 173 | 171,dML 174 | 172,dAB1 175 | 173,dAB2 176 | 174,dAB3 177 | 175,dAB4 178 | 176,dES 179 | 177,dAS 180 | 178,dAI 181 | 179,dS 182 | 180,dRL 183 | 181,dCV5RL 184 | 182,dCV6LL 185 | 183,dCV6LU 186 | 184,dV10 187 | """ -------------------------------------------------------------------------------- /scp.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | 5 | class ScpRecord: 6 | """Container for all SCP sections""" 7 | def __init__(self): 8 | self.crc = 0 9 | self.len = 0 10 | self.sections = [] 11 | 12 | def has_section(self, section_id): 13 | """Return True if section with section_id exists""" 14 | return self.section(section_id) is not None 15 | 16 | def section(self, section_id): 17 | """Return section with given id""" 18 | for s in self.sections: 19 | if s.h.id == section_id: 20 | return s 21 | return None 22 | 23 | def number_of_leads(self): 24 | """Returns number of leads from section 3""" 25 | s3 = self.section(3) 26 | if s3: 27 | return len(s3.leads) 28 | return 0 29 | 30 | 31 | class SectionPointer: 32 | """A Section pointer""" 33 | def __init__(self): 34 | # section id number 35 | self.id = 0 36 | # section length 37 | self.len = 0 38 | # byte position of section starting from zero 39 | self.index = 0 40 | 41 | def __str__(self): 42 | return '{0}({1})'.format(self.id, self.len) 43 | 44 | def section_has_data(self): 45 | """Return True if section is not empty""" 46 | return self.len > 0 47 | 48 | 49 | class Section(): 50 | """Super class for each Section, which contains a header""" 51 | def __init__(self, scpHeader): 52 | self.h = scpHeader 53 | 54 | 55 | class SectionHeader: 56 | """A section header for each section""" 57 | def __init__(self): 58 | self.crc = 0 59 | # section id number 60 | self.id = 0 61 | self.len = 0 62 | # section version number 63 | self.versnr = 0 64 | # protocol version number 65 | self.protnr = 0 66 | self.reserved = None 67 | 68 | def __str__(self): 69 | return 'crc:{0},id:{1},len:{2},ver:{3},prot:{4},resvd:{5}'\ 70 | .format(self.crc, self.id, self.len, self.versnr, self.protnr, self.reserved) 71 | 72 | 73 | class Tag: 74 | """A tag""" 75 | def __init__(self): 76 | self.tag = 0 77 | self.len = 0 78 | self.data = None 79 | 80 | 81 | class Section0(Section): 82 | """Section 0 wich contains pointers to other sections""" 83 | def __init__(self, header): 84 | super().__init__(header) 85 | self.p = [] 86 | 87 | def has_section(self, section_id): 88 | """Retturn True if section with id exists""" 89 | p = self.pointer_for_section(section_id) 90 | if p is None: 91 | return False 92 | return p.len > 0 93 | 94 | def pointer_for_section(self, section_id): 95 | """Returns pointer for the section with id""" 96 | for p in self.p: 97 | if p.id == section_id: 98 | return p 99 | return None 100 | 101 | 102 | class LeadIdentification: 103 | """LeadIdenticitation with information about sample count (Section 3)""" 104 | def __init__(self): 105 | self.startsample = 0 106 | self.endsample = 0 107 | self.leadid = 0 108 | 109 | def __str__(self): 110 | return '{0} ({1})'.format(self.leadid, self.sample_count()) 111 | 112 | def sample_count(self): 113 | """Return number of samples for this LeadId""" 114 | return self.endsample - self.startsample + 1 115 | 116 | 117 | class DataSamples: 118 | """ 119 | Data samples for a lead, for Sections 5 (ref. beat samples) and Section 6 (samples) 120 | """ 121 | 122 | def __init__(self): 123 | self.samples = [] 124 | 125 | class Section1(Section): 126 | """Section 1 - Patient data, ECG Aquisition data""" 127 | def __init__(self, header, pointer): 128 | super().__init__(header) 129 | self.p = pointer 130 | # tags 10,13,30,32,35 may exist multiple times 131 | self.tags = [] 132 | self.datalen = 0 133 | 134 | 135 | class Section2(Section): 136 | """Section 2 - Huffman tables used in encoding of Ecg data""" 137 | def __init__(self, header, pointer): 138 | super().__init__(header) 139 | self.p = pointer 140 | # Number of Huffman Tables defined (if 19 999 then the default table, defined in C.3.7.6, is used). 141 | self.nr_huffman_tables = 0 142 | # Number of code structures in table # 1 143 | self.nr_code_struct = 0 144 | 145 | 146 | class Section3(Section): 147 | """Section 3 - ECG Leads definition""" 148 | def __init__(self, header, pointer): 149 | super().__init__(header) 150 | self.p = pointer 151 | self.nrleads = 0 152 | self.flags = 0 153 | # first bit 154 | self.ref_beat_substr = False 155 | # bits 3-7 156 | self.nr_leads_sim = 0 157 | self.leads = [] 158 | 159 | # QRS Locations if reference beats are encoded 160 | class Section4(Section): 161 | """Section 4 - Reserved for legacy SCP-ECG versions (SCP Versions 1.x, 2.x)""" 162 | def __init__(self, header, pointer): 163 | super().__init__(header) 164 | self.p = pointer 165 | # Length of reference beat type 0 in milliseconds 166 | self.ref_beat_type_len = 0 167 | # Sample number of the fiducial point (QRS trigger point), with respect to beginning of reference beat type 0 168 | self.sample_nr_fidpoint = 0 169 | # Total number of QRS complexes within the entire short-term ECG rhythm record 170 | self.total_nr_qrs = 0 171 | 172 | class Section5(Section): 173 | """Section 5 with samples""" 174 | def __init__(self, header, pointer): 175 | super().__init__(header) 176 | self.p = pointer 177 | # avm in nanovolt 178 | self.avm = 0 179 | # sample time interval in ms 180 | self.sample_time_interval = 0 181 | # 0,1,2 182 | self.sample_encoding = 0 183 | self.reserved = 0 184 | 185 | # number of bytes for encoded leads 186 | # in the same order as the leads are stored 187 | # lead order comes from section3 188 | self.nr_bytes_for_leads = [] 189 | self.data = [] 190 | 191 | 192 | class Section6(Section): 193 | """Section 6 - Long-term ECG rythm data""" 194 | def __init__(self, header, pointer): 195 | super().__init__(header) 196 | self.p = pointer 197 | # Amplitude Value Multiplier in nanovolt 198 | self.avm = 0 199 | self.sample_time_interval = 0 200 | # 0,1,2 201 | self.sample_encoding = 0 202 | # rythm data compression 203 | self.bimodal_compression = 0 204 | 205 | # number of bytes for encoded leads 206 | # in the same order as the leads are stored 207 | # lead order comes from section3 208 | self.nr_bytes_for_leads = [] 209 | self.data = [] 210 | 211 | 212 | class Section7(Section): 213 | """Section 7 - Global ECG Measurements""" 214 | def __init__(self, header, pointer): 215 | super().__init__(header) 216 | self.p = pointer 217 | 218 | self.reference_count = 0 219 | # number of pacemaker spikes 220 | self.pace_count = 0 221 | # Average RR interval in milliseconds for all QRS's 222 | self.rr_interval = 0 223 | # Average PP interval in milliseconds for all QRS's 224 | self.pp_interval = 0 225 | self.pace_times = [] 226 | self.pace_amplitudes = [] 227 | self.pace_types = [] 228 | self.pace_sources = [] 229 | self.pace_indexes = [] 230 | self.pace_widths = [] 231 | 232 | class Section8(Section): 233 | """Section 8 - Textual diagnosis""" 234 | def __init__(self, header, pointer): 235 | super().__init__(header) 236 | self.p = pointer 237 | 238 | 239 | class Section9(Section): 240 | """Section 9 - Manufacturer Specific diagnostic and overreading data""" 241 | def __init__(self, header, pointer): 242 | super().__init__(header) 243 | self.p = pointer 244 | 245 | 246 | class Section10(Section): 247 | """Section 10 - per Lead ECG Measurements""" 248 | def __init__(self, header, pointer): 249 | super().__init__(header) 250 | self.p = pointer 251 | 252 | 253 | class Section11(Section): 254 | """Section 11 - Universal Statement Codes resulting from the interpretation""" 255 | def __init__(self, header, pointer): 256 | super().__init__(header) 257 | self.p = pointer 258 | 259 | 260 | class Section12(Section): 261 | """Section 12 - Long-Term ECG Rythm data""" 262 | def __init__(self, header, pointer): 263 | super().__init__(header) 264 | self.p = pointer 265 | -------------------------------------------------------------------------------- /scpformat.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | SCP section and attribute formatter 6 | """ 7 | 8 | from scputil import b2i, b2s, bdecode, lead_dic 9 | 10 | 11 | class Section1TagsFormatter: 12 | def __init__(self, tags): 13 | self.tags = tags 14 | 15 | def format_tag_int(self, tag, start, end): 16 | for _tag in self.tags: 17 | if _tag.tag == tag: 18 | return b2i(_tag.data[start:end]) 19 | return '' 20 | 21 | def get_tag(self, id): 22 | """Return tag with id 'id'""" 23 | for _tag in self.tags: 24 | if _tag.tag == id: 25 | return _tag 26 | return None 27 | 28 | def format_tag(self, tag): 29 | """Format given tag""" 30 | for _tag in self.tags: 31 | if _tag.tag == tag: 32 | return bdecode(_tag.data) 33 | return '' 34 | 35 | # multiple tags 36 | def tag_list(self, tag_id): 37 | """Return a tag list for given tag id""" 38 | mytags = [] 39 | for _tag in self.tags: 40 | if _tag.tag == tag_id: 41 | mytags.append(_tag) 42 | return mytags 43 | 44 | def tag_data(self, tag_id): 45 | """Return tag data for tag with id 'tag_id'""" 46 | for _tag in self.tags: 47 | if _tag.tag == tag_id: 48 | return _tag.data 49 | 50 | return None 51 | 52 | def format(self, printer): 53 | printer.p('LastName', self.format_tag(0)) 54 | printer.p('FirstName', self.format_tag(1)) 55 | printer.p('Pat Id', self.format_tag(2)) 56 | printer.p('Second LastName', self.format_tag(3)) 57 | PatientAgeFormatter(self.tag_data(4)).format(printer) 58 | DateOfBirthFormatter(self.tag_data(5)).format(printer) 59 | PatientHeightFormatter(self.tag_data(6)).format(printer) 60 | PatientWeightFormatter(self.tag_data(7)).format(printer) 61 | PatientSexFormatter(self.tag_data(8)).format(printer) 62 | PatientRaceFormatter(self.tag_data(9)).format(printer) 63 | for t in self.tag_list(10): 64 | DrugsFormatter(t.data).format(printer) 65 | printer.p('Sys (mmHg)', self.format_tag_int(11, 0, 2)) 66 | printer.p('Dia (mmHg)', self.format_tag_int(12, 0, 2)) 67 | TagMachineId(self.tag_data(14)).format(printer) 68 | 69 | printer.p('Acq. Institution Desc', self.format_tag(16)) 70 | printer.p('Acq. Institution Desc', self.format_tag(17)) 71 | printer.p('Acq. Department Desc', self.format_tag(18)) 72 | printer.p('Anal. Department Desc', self.format_tag(19)) 73 | printer.p('Referring Physician', self.format_tag(20)) 74 | printer.p('Latest Confirm. Phys', self.format_tag(21)) 75 | printer.p('Technician Physician', self.format_tag(22)) 76 | printer.p('Room Description', self.format_tag(23)) 77 | printer.p('Stat Code', self.format_tag(24)) 78 | DateOfAcquisitionFormatter(self.tag_data(25)).format(printer) 79 | TimeOfAcquisitionFormatter(self.tag_data(26)).format(printer) 80 | printer.p('Baseline Filter', self.format_tag_int(27, 0, 2)) 81 | printer.p('LowPass Filter', self.format_tag_int(28, 0, 2)) 82 | FilterBitMapFormatter(self.tag_data(29)).format(printer) 83 | for t in self.tag_list(30): 84 | printer.p('Text', bdecode(t.data)) 85 | printer.p('Ecg Seq.', self.format_tag(31)) 86 | ElectrodeConfigFormatter(self.tag_data(33)).format(printer) 87 | DateTimeZoneFormatter(self.tag_data(34)).format(printer) 88 | for t in self.tag_list(35): 89 | printer.p('Med. History', bdecode(t.data)) 90 | 91 | 92 | # Lead Format 93 | class LeadIdFormatter: 94 | def __init__(self, leads): 95 | self.leads = leads 96 | self.leadnames = lead_dic() 97 | 98 | def _leadname(self, key): 99 | name = self.leadnames[key] 100 | if name: 101 | return name 102 | return str(key) 103 | 104 | def format(self, printer): 105 | s = ', '.join(self._leadname(lead.leadid) for lead in self.leads) 106 | printer.p('Leads', s) 107 | s2 = ', '.join('{0} ({1})'.format(self._leadname( 108 | lead.leadid), lead.sample_count()) for lead in self.leads) 109 | printer.p('SampleCount', s2) 110 | 111 | class ElectrodeConfigFormatter: 112 | """Tag 33""" 113 | def __init__(self, bytes): 114 | self._print = False 115 | if bytes and len(bytes) > 1: 116 | self.value1 = b2i(bytes[0:1]) 117 | self.value2 = b2i(bytes[1:2]) 118 | self._print = True 119 | 120 | def format(self, printer): 121 | if self._print: 122 | printer.p('Electrode Config.', 123 | '{0}/{1}'.format(self.value1, self.value2)) 124 | 125 | 126 | class DateTimeZoneFormatter: 127 | """Tag 34""" 128 | def __init__(self, bytes): 129 | self._print = False 130 | if bytes: 131 | # in minutes 132 | self.offset = b2s(bytes[0:2]) 133 | self.index = b2i(bytes[2:4]) 134 | self.desc = bdecode(bytes[4:]) 135 | self._print = True 136 | 137 | def format(self, printer): 138 | if self._print: 139 | printer.p('TimeZone', 'Offset:{0}m, Idx:{1}, Desc:{2}'.format( 140 | self.offset, self.index, self.desc)) 141 | 142 | 143 | class PatientRaceFormatter: 144 | """Tag 9""" 145 | 146 | def __init__(self, bytes): 147 | self.lookup = { 148 | 0: 'Unspecified', 149 | 1: 'Caucasian', 150 | 2: 'Black', 151 | 3: 'Oriental' 152 | } 153 | 154 | # 1 byte 155 | if bytes: 156 | value = b2i(bytes) 157 | self.text = self.lookup[value] 158 | else: 159 | self.text = '' 160 | 161 | def __str__(self): 162 | return self.text 163 | 164 | def format(self, printer): 165 | printer.p('Race', self.text) 166 | 167 | 168 | 169 | class FilterBitMapFormatter: 170 | """Tag 29""" 171 | def __init__(self, bytes): 172 | self.value = '' 173 | 174 | self.lookup = { 175 | 0: '60Hz notch filter', 176 | 1: '50Hz notch filter', 177 | 2: 'Artifact filter', 178 | 3: 'Basefilter filter' 179 | } 180 | 181 | if bytes: 182 | v = b2i(bytes) 183 | if v in self.lookup: 184 | self.value = self.lookup[v] 185 | else: 186 | self.value = v 187 | 188 | def format(self, printer): 189 | printer.p('Other Filters', self.value) 190 | 191 | 192 | class DrugsFormatter: 193 | def __init__(self, bytes): 194 | self.value = '' 195 | if bytes and len(bytes) > 4: 196 | self.value = bdecode(bytes[4:]) 197 | 198 | def format(self, printer): 199 | printer.p('Drugs', self.value) 200 | 201 | 202 | class PatientAgeFormatter: 203 | def __init__(self, bytes): 204 | self.value = '' 205 | if bytes and len(bytes) > 2: 206 | self.value = b2i(bytes[0:2]) 207 | 208 | def __str__(self): 209 | return str(self.value) 210 | 211 | def format(self, printer): 212 | printer.p('Age', self.value) 213 | 214 | 215 | class DateOfAcquisitionFormatter: 216 | """Tag 25""" 217 | def __init__(self, bytes): 218 | self.value = '' 219 | if bytes and len(bytes) > 3: 220 | # y/m/d 221 | self.value = '{0}/{1}/{2}'.format( 222 | b2i(bytes[0:2]), b2i(bytes[2:3]), b2i(bytes[3:4])) 223 | 224 | def format(self, printer): 225 | printer.p('Date of Acquis.', self.value) 226 | 227 | 228 | class TimeOfAcquisitionFormatter: 229 | """Tag 26""" 230 | def __init__(self, bytes): 231 | self.value = '' 232 | if bytes and len(bytes) > 2: 233 | # H:m:s 234 | h = b2i(bytes[0:1]) 235 | m = b2i(bytes[1:2]) 236 | s = b2i(bytes[2:3]) 237 | self.value = '{0}:{1}:{2}'.format( 238 | str(h).zfill(2), str(m).zfill(2), str(s).zfill(2)) 239 | 240 | def format(self, printer): 241 | printer.p('Time of Acquis.', self.value) 242 | 243 | 244 | class PatientHeightFormatter: 245 | """Tag 7""" 246 | def __init__(self, bytes): 247 | self.lookup = { 248 | 0: 'Unspecified', 249 | 1: 'cm', 250 | 2: 'inch', 251 | 3: 'mm' 252 | } 253 | self.text = '' 254 | 255 | if bytes: 256 | height = b2i(bytes[0:1]) 257 | unit = b2i(bytes[2:3]) 258 | self.text = '{0} {1}'.format(height, self.lookup[unit]) 259 | 260 | def __str__(self): 261 | return self.text 262 | 263 | def format(self, printer): 264 | printer.p('Height', self.text) 265 | 266 | class PatientWeightFormatter: 267 | """Tag 7""" 268 | def __init__(self, bytes): 269 | self.lookup = { 270 | 0: 'Unspecified', 271 | 1: 'kg', 272 | 2: 'g', 273 | 3: 'Pound', 274 | 4: 'Ounce' 275 | } 276 | self.text = '' 277 | 278 | if bytes: 279 | weight = b2i(bytes[0:1]) 280 | unit = b2i(bytes[2:3]) 281 | self.text = '{0} {1}'.format(weight, self.lookup[unit]) 282 | 283 | def __str__(self): 284 | return self.text 285 | 286 | def format(self, printer): 287 | printer.p('Weight', self.text) 288 | 289 | class PatientSexFormatter: 290 | """Tag 8""" 291 | def __init__(self, bytes): 292 | self.lookup = { 293 | 0: 'Not Known', 294 | 1: 'Male', 295 | 2: 'Female', 296 | 9: 'Unspecified' 297 | } 298 | self.text = '' 299 | 300 | if bytes: 301 | key = b2i(bytes) 302 | if key in self.lookup: 303 | self.text = self.lookup[key] 304 | else: 305 | self.text = key 306 | 307 | def __str__(self): 308 | return self.text 309 | 310 | def format(self, printer): 311 | printer.p('Sex', self.text) 312 | 313 | 314 | class DateOfBirthFormatter: 315 | """Tag 5""" 316 | def __init__(self, bytes): 317 | if bytes and len(bytes) > 2: 318 | self.text = '{0}/{1}/{2}'.format( 319 | b2i(bytes[0:2]), b2i(bytes[2:3]), b2i(bytes[3:4])) 320 | else: 321 | self.text = '' 322 | 323 | def __str__(self): 324 | return self.text 325 | 326 | def format(self, printer): 327 | printer.p('DateOfBirth', self.text) 328 | 329 | 330 | 331 | class TagMachineId: 332 | """Tag 14""" 333 | def __init__(self, bytes): 334 | if bytes: 335 | self.instNr = b2i(bytes[0:2]) 336 | self.depNr = b2i(bytes[2:4]) 337 | self.devId = b2i(bytes[4:4]) 338 | self.devType = b2i(bytes[6:6]) 339 | self.model = bdecode(bytes[9:14]) 340 | 341 | def __str__(self): 342 | return '--' 343 | 344 | def format(self, printer): 345 | printer.p('Acq. Device MachineID', '----') 346 | printer.p('InstNr', self.instNr) 347 | printer.p('DepNr', self.depNr) 348 | printer.p('DevId', self.devId) 349 | printer.p('DevType', self.devType) 350 | printer.p('Model', self.model) 351 | printer.p('', '----') 352 | 353 | 354 | def format_section(s, printer): 355 | if s.h.id == 0: 356 | format_section0(s, printer) 357 | elif s.h.id == 1: 358 | format_section1(s, printer) 359 | elif s.h.id == 2: 360 | format_section2(s, printer) 361 | elif s.h.id == 3: 362 | format_section3(s, printer) 363 | elif s.h.id == 4: 364 | format_section4(s, printer) 365 | elif s.h.id == 5: 366 | format_section5(s, printer) 367 | elif s.h.id == 6: 368 | format_section6(s, printer) 369 | elif s.h.id == 7: 370 | format_section7(s, printer) 371 | elif s.h.id == 8: 372 | format_section8(s, printer) 373 | else: 374 | format_section_default(s, s.h.id, printer) 375 | 376 | 377 | def format_section0(s0, printer): 378 | # section 0 379 | printer.p('--Section0--', '----') 380 | format_header(s0.h, printer) 381 | 382 | printer.p('Pointer Count', len(s0.p)) 383 | printer.p('Pointers', ', '.join(str(p) for p in s0.p)) 384 | 385 | 386 | def format_section1(s1, printer): 387 | if not s1.p.section_has_data(): 388 | return 389 | 390 | print() 391 | printer.p('--Section1--', '----') 392 | format_header(s1.h, printer) 393 | printer.p('Res', s1.h.reserved) 394 | printer.p('Tags', len(s1.tags)) 395 | Section1TagsFormatter(s1.tags).format(printer) 396 | 397 | 398 | def format_section2(s2, printer): 399 | if not s2.p.section_has_data(): 400 | return 401 | 402 | print() 403 | printer.p('--Section2--', '----') 404 | format_header(s2.h, printer) 405 | printer.p('NrHuffmanTables', '19999 (default table)' if s2.nr_huffman_tables == 19999 else s2.nr_huffman_tables) 406 | 407 | 408 | def format_section3(s3, printer): 409 | if not s3.p.section_has_data(): 410 | return 411 | 412 | print() 413 | printer.p('--Section3--', '----') 414 | format_header(s3.h, printer) 415 | printer.p('RefBeatSet', s3.ref_beat_substr) 416 | printer.p('Sim-rec Leads', s3.nr_leads_sim) 417 | printer.p('LeadCount', len(s3.leads)) 418 | LeadIdFormatter(s3.leads).format(printer) 419 | 420 | def format_section5(s5, printer): 421 | if not s5.p.section_has_data(): 422 | return 423 | 424 | enc_table = { 425 | 0: 'Real', 426 | 1: 'First difference', 427 | 2: 'Second difference' 428 | } 429 | 430 | print() 431 | printer.p('--Section5--', '----') 432 | format_header(s5.h, printer) 433 | printer.p('AVM (nV)', s5.avm) 434 | printer.p('SampleTime (µs)', s5.sample_time_interval) 435 | printer.p('Sample Encoding', enc_table[s5.sample_encoding]) 436 | printer.p('RefBeat 0, Bytes', ', '.join(str(nr) 437 | for nr in s5.nr_bytes_for_leads)) 438 | 439 | 440 | def format_samples_as_csv(scp, section_id, printer): 441 | if section_id not in (5,6): 442 | return 443 | 444 | if scp.has_section(section_id): 445 | lead_name_dic = lead_dic() 446 | lead_names = [] 447 | if scp.has_section(3): 448 | leads = scp.section(3).leads 449 | lead_names = list(lead_name_dic[lead.leadid] for lead in leads) 450 | format_section_samples(scp.section(section_id), lead_names, printer) 451 | 452 | 453 | def format_section_samples(section, leads_names, printer): 454 | mylist = [] 455 | for s in section.data: 456 | mylist.append(s.samples) 457 | row_format = "{:>7}," * (len(mylist)) 458 | z = zip(*mylist) 459 | 460 | # header 461 | print(row_format.format(*leads_names)) 462 | # table with samples 463 | try: 464 | for row in list(z): 465 | print(row_format.format(*row)) 466 | except (BrokenPipeError, IOError): 467 | pass 468 | 469 | 470 | def format_section6(s6, printer): 471 | if not s6.p.section_has_data(): 472 | return 473 | 474 | enc_table = { 475 | 0: 'Real', 476 | 1: 'First difference', 477 | 2: 'Second difference' 478 | } 479 | 480 | print() 481 | printer.p('--Section6--', '----') 482 | format_header(s6.h, printer) 483 | printer.p('AVM (nV)', s6.avm) 484 | printer.p('SampleTime (µs)', s6.sample_time_interval) 485 | printer.p('Sample Encoding', enc_table[s6.sample_encoding]) 486 | printer.p('Bimodal compression', s6.bimodal_compression == 1) 487 | printer.p('Lead Samples, Bytes', ', '.join(str(nr) 488 | for nr in s6.nr_bytes_for_leads)) 489 | 490 | 491 | def format_section7(s7, printer): 492 | if not s7.p.section_has_data(): 493 | return 494 | 495 | print() 496 | printer.p('--Section7--', '----') 497 | format_header(s7.h, printer) 498 | printer.p('ReferenceCount', s7.reference_count) 499 | printer.p('PaceCount', s7.pace_count) 500 | printer.p('Avg RR Interval (ms)', "29999 (not calculated)" if 29999 == s7.rr_interval else s7.rr_interval ) 501 | printer.p('Avg PP Interval (ms)', "29999 (not calculated)" if 29999 == s7.pp_interval else s7.pp_interval) 502 | 503 | 504 | def format_section4(s, printer): 505 | if not s.p.section_has_data(): 506 | return 507 | 508 | print() 509 | printer.p('--Section4--', '----') 510 | format_header(s.h, printer) 511 | printer.p('RefBeatType0Len', s.ref_beat_type_len) 512 | printer.p('SampleNr FiducialPoint', s.sample_nr_fidpoint) 513 | printer.p('TotalNrQRS', s.total_nr_qrs) 514 | 515 | 516 | def format_section8(s8, printer): 517 | if not s8.p.section_has_data(): 518 | return 519 | 520 | print() 521 | printer.p('--Section8--', '----') 522 | format_header(s8.h, printer) 523 | 524 | def format_section_default(s, section_id, printer): 525 | if not s.p.section_has_data(): 526 | return 527 | 528 | print() 529 | printer.p('--Section' + str(section_id) + '--', '----') 530 | format_header(s.h, printer) 531 | 532 | def format_header(h, printer): 533 | printer.p('--Header--', '----') 534 | printer.p('CRC', h.crc) 535 | printer.p('Id:', h.id) 536 | printer.p('Length', h.len) 537 | printer.p('VersionNr', h.versnr) 538 | printer.p('ProtocolNr', h.protnr) 539 | printer.p('Reserved', h.reserved) 540 | printer.p('----', '----') 541 | -------------------------------------------------------------------------------- /scpinfo.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import argparse 5 | 6 | from scpformat import format_section, format_samples_as_csv 7 | from scpreader import FileReader, ScpReader 8 | from scputil import ScpPrinter, lead_dic 9 | 10 | 11 | def format_scp(f, csv_argument): 12 | """ 13 | Format scp file 'f' 14 | 15 | csv_argument - optional section id for CSV output 16 | """ 17 | fr = FileReader(f) 18 | scpReader = ScpReader(fr) 19 | 20 | scp = scpReader.read_scp() 21 | scpReader.close() 22 | 23 | printer = ScpPrinter() 24 | 25 | if csv_argument is not None: 26 | format_samples_as_csv(scp, csv_argument[0], printer) 27 | return 28 | 29 | printer.p('--ScpRec--', '----') 30 | printer.p('CRC', scp.crc) 31 | printer.p('RecLen', scp.len) 32 | 33 | for s in scp.sections: 34 | format_section(s, printer) 35 | 36 | 37 | def argparser(): 38 | parser = argparse.ArgumentParser() 39 | parser.add_argument('scpfile', help='input SCP file') 40 | parser.add_argument( 41 | '--csv', type=int, nargs=1, metavar='section_id', help='print leads in CSV format for section 5 or 6, specify here a section id') 42 | return parser 43 | 44 | 45 | def main(): 46 | args = argparser().parse_args() 47 | if args.scpfile: 48 | format_scp(args.scpfile, args.csv) 49 | 50 | if __name__ == "__main__": 51 | main() 52 | -------------------------------------------------------------------------------- /scpreader.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from scp import * 5 | 6 | import struct 7 | 8 | 9 | class FileReader: 10 | """ 11 | Reads an scp file in binary format, 12 | offers some handy methods to jump in file and read chunks of bytes as string or int 13 | """ 14 | def __init__(self, filename): 15 | self.filename = filename 16 | self.file = open(filename, 'rb') 17 | 18 | def read(self, n): 19 | """Read number 'n' of bytes""" 20 | return self.file.read(n) 21 | 22 | def pos(self): 23 | """Tell the current position of file read pointer""" 24 | return self.file.tell() 25 | 26 | def readint(self, n): 27 | """Read n bytes and converts to int (little endian)""" 28 | bytes = self.file.read(n) 29 | if len(bytes) == 0: 30 | print('ERR: Could not readint, corrupt structure. File position ' + self.pos()) 31 | # TODO throw error 32 | 33 | if n == 1: 34 | # little endian, 8bit int 35 | value = struct.unpack("B", bytes)[0] 36 | return int(value) 37 | elif n == 2: 38 | # little endian, 16bit signed short 39 | value = struct.unpack(" 0: 161 | # FIXME check starting range 162 | for _ in range(1, restlen/10): 163 | pointer = self._sectionpointer() 164 | s.p.append(pointer) 165 | return s 166 | 167 | def _readtag(self): 168 | """Read and return a scp tag""" 169 | tag = Tag() 170 | tag.tag = self.reader.read_byte() 171 | tag.len = self.reader.read_ushort() 172 | 173 | if tag.len > 0: 174 | tag.data = self.reader.read(tag.len) 175 | return tag 176 | 177 | def _readleadid(self): 178 | """Read and return a LeadId""" 179 | leadid = LeadIdentification() 180 | leadid.startsample = self.reader.read_uint() 181 | leadid.endsample = self.reader.read_uint() 182 | leadid.leadid = self.reader.read_byte() 183 | return leadid 184 | 185 | def _read_section(self, pointer, nr_of_leads): 186 | """Read a section by given pointer id""" 187 | if pointer.id == 1: 188 | return self._section1(pointer) 189 | if pointer.id == 2: 190 | return self._section2(pointer) 191 | elif pointer.id == 3: 192 | return self._section3(pointer) 193 | elif pointer.id == 4: 194 | return self._section4(pointer) 195 | elif pointer.id == 5: 196 | return self._section5(pointer, nr_of_leads) 197 | elif pointer.id == 6: 198 | return self._section6(pointer, nr_of_leads) 199 | elif pointer.id == 7: 200 | return self._section7(pointer) 201 | elif pointer.id == 8: 202 | return self._section8(pointer) 203 | elif pointer.id == 9: 204 | return self._section9(pointer) 205 | elif pointer.id == 10: 206 | return self._section10(pointer) 207 | elif pointer.id == 11: 208 | return self._section11(pointer) 209 | elif pointer.id == 12: 210 | return self._section12(pointer) 211 | elif pointer.id > 12: 212 | print("WARN: Section Id %s is not implemented" % str(pointer.id)) 213 | return None 214 | 215 | def _section1(self, pointer): 216 | """Read section 1""" 217 | self.reader.move(pointer.index - 1) 218 | 219 | header = self._sectionheader() 220 | s = Section1(header, pointer) 221 | s.datalen = header.len - 16 222 | start = s.datalen 223 | 224 | # all tags in section 225 | while (start > 0): 226 | tag = self._readtag() 227 | start = start - tag.len 228 | s.tags.append(tag) 229 | return s 230 | 231 | def _section2(self, pointer): 232 | """Read section 2""" 233 | self.reader.move(pointer.index - 1) 234 | 235 | header = self._sectionheader() 236 | s = Section2(header, pointer) 237 | 238 | s.nr_huffman_tables = self.reader.read_ushort() 239 | # Number of code structures in table # 1 240 | s.nr_code_struct = self.reader.read_ushort() 241 | return s 242 | 243 | def _section3(self, pointer): 244 | """Read section 3""" 245 | self.reader.move(pointer.index - 1) 246 | 247 | header = self._sectionheader() 248 | s = Section3(header, pointer) 249 | 250 | s.nrleads = self.reader.read_byte() 251 | s.flags = self.reader.read_byte() 252 | # first bit 253 | s.ref_beat_substr = bool(s.flags >> 1 & 1) 254 | # bits 3-7 255 | s.nr_leads_sim = s.flags >> 3 & 0b1111 256 | 257 | s.leads = [] 258 | for _ in range(0, s.nrleads): 259 | lead = self._readleadid() 260 | s.leads.append(lead) 261 | return s 262 | 263 | def _section4(self, pointer): 264 | """Read section 4""" 265 | self.reader.move(pointer.index - 1) 266 | 267 | header = self._sectionheader() 268 | s = Section4(header, pointer) 269 | 270 | s.ref_beat_type_len = self.reader.read_ushort() 271 | s.sample_nr_fidpoint = self.reader.read_ushort() 272 | s.total_nr_qrs = self.reader.read_ushort() 273 | 274 | return s 275 | 276 | def _section5(self, pointer, nr_of_leads): 277 | """Read section 5""" 278 | self.reader.move(pointer.index - 1) 279 | 280 | header = self._sectionheader() 281 | s = Section5(header, pointer) 282 | 283 | s.avm = self.reader.read_ushort() 284 | s.sample_time_interval = self.reader.read_ushort() 285 | s.sample_encoding = self.reader.read_byte() 286 | s.reserved = self.reader.read_byte() 287 | 288 | # nr of bytes for each lead 289 | for _ in range(0, nr_of_leads): 290 | s.nr_bytes_for_leads.append(self.reader.read_ushort()) 291 | 292 | # samples for each lead 293 | for nr in s.nr_bytes_for_leads: 294 | data = DataSamples() 295 | # ref. beat samples are store as 2byte signed ints 296 | # if section2 is not provided 297 | samples_len = nr / 2 298 | 299 | while samples_len > 0: 300 | data.samples.append(self.reader.read_ushort()) 301 | samples_len = samples_len - 1 302 | 303 | s.data.append(data) 304 | 305 | return s 306 | 307 | def _section6(self, pointer, nr_of_leads): 308 | """Read section 6""" 309 | self.reader.move(pointer.index - 1) 310 | 311 | header = self._sectionheader() 312 | s = Section6(header, pointer) 313 | 314 | s.avm = self.reader.read_ushort() 315 | s.sample_time_interval = self.reader.read_ushort() 316 | s.sample_encoding = self.reader.read_byte() 317 | s.bimodal_compression = self.reader.read_byte() 318 | 319 | 320 | # nr of bytes for each lead 321 | for _ in range(0, nr_of_leads): 322 | s.nr_bytes_for_leads.append(self.reader.read_ushort()) 323 | 324 | # TODO: check if section2 exists -> samples are Huffman encoded 325 | 326 | # samples for each lead 327 | for nr in s.nr_bytes_for_leads: 328 | data = DataSamples() 329 | # samples are store as 2byte signed ints 330 | # if section2 is not provided 331 | samples_len = nr / 2 332 | 333 | while samples_len > 0: 334 | # signed 2byte integers 335 | data.samples.append(self.reader.readint(2)) 336 | samples_len = samples_len - 1 337 | 338 | s.data.append(data) 339 | return s 340 | 341 | def _section7(self, pointer): 342 | """Read section 7""" 343 | self.reader.move(pointer.index - 1) 344 | 345 | header = self._sectionheader() 346 | s = Section7(header, pointer) 347 | 348 | s.reference_count = self.reader.read_byte() 349 | s.pace_count = self.reader.read_byte() 350 | s.rr_interval = self.reader.read_ushort() 351 | s.pp_interval = self.reader.read_ushort() 352 | for i in range(0, s.pace_count): 353 | s.pace_times[i] = self.reader.readint(2) 354 | s.pace_amplitudes[i] = self.reader.read_ushort() 355 | 356 | for i in range(0, s.pace_count): 357 | s.pace_types = self.reader.read_byte() 358 | s.pace_sources = self.reader.read_byte() 359 | s.pace_indexes = self.reader.readint(2) 360 | s.pace_widths = self.reader.readint(2) 361 | 362 | return s 363 | 364 | 365 | def _section8(self, pointer): 366 | """Read section 8""" 367 | self.reader.move(pointer.index - 1) 368 | 369 | header = self._sectionheader() 370 | s = Section8(header, pointer) 371 | 372 | return s 373 | 374 | def _section9(self, pointer): 375 | """Read section 9""" 376 | self.reader.move(pointer.index - 1) 377 | 378 | header = self._sectionheader() 379 | s = Section9(header, pointer) 380 | 381 | return s 382 | 383 | 384 | def _section10(self, pointer): 385 | """Read section 10""" 386 | self.reader.move(pointer.index - 1) 387 | 388 | header = self._sectionheader() 389 | s = Section10(header, pointer) 390 | 391 | return s 392 | 393 | def _section11(self, pointer): 394 | """Read section 11""" 395 | self.reader.move(pointer.index - 1) 396 | 397 | header = self._sectionheader() 398 | s = Section11(header, pointer) 399 | 400 | return s 401 | 402 | def _section12(self, pointer): 403 | """Read section 12""" 404 | self.reader.move(pointer.index - 1) 405 | 406 | header = self._sectionheader() 407 | s = Section12(header, pointer) 408 | 409 | return s 410 | -------------------------------------------------------------------------------- /scputil.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import struct 5 | from leadtable import lead_table 6 | 7 | def b2s(bytes): 8 | if len(bytes) != 2: 9 | raise ValueError("bytes must be exactly 2 bytes long") 10 | return struct.unpack('h', bytes) 11 | 12 | def b2b(bytes): 13 | if len(bytes) != 1: 14 | raise ValueError("bytes must be exactly 1 byte long") 15 | return struct.unpack('B', bytes) 16 | 17 | def b2i(bytes): 18 | """Convert bytes to unsigned integer (little endian)""" 19 | return int.from_bytes(bytes, 'little') 20 | 21 | def b2si(bytes): 22 | """Convert bytes to signed integer (little endian)""" 23 | return int.from_bytes(bytes, 'little', signed=True) 24 | 25 | def bdecode(bytes): 26 | """Decode bytes as iso-8859-1 and remove null terminators""" 27 | # remove null terminators 28 | return bytes.decode('iso-8859-1').rstrip('\0') 29 | 30 | 31 | def lead_dic(): 32 | """Return lead dictionary from file""" 33 | data_dict = {} 34 | lines = lead_table.split('\n') 35 | for line in lines: 36 | if line: 37 | key, value = line.split(',') 38 | data_dict[int(key)] = value 39 | return data_dict 40 | 41 | 42 | def file2dict(file): 43 | """Converts a file to dictionary""" 44 | d = {} 45 | with open(file) as f: 46 | for line in f: 47 | (key, val) = line.split(',') 48 | d[int(key.strip())] = val.strip() 49 | return d 50 | 51 | # https://gist.github.com/oysstu/68072c44c02879a2abf94ef350d1c7c6 52 | 53 | 54 | def crc16(data: bytes, poly=0x1021): 55 | ''' 56 | CRC-16-CCITT Algorithm checksum 57 | ''' 58 | #data = bytearray(data) 59 | crc = 0xFFFF 60 | for b in data: 61 | cur_byte = 0xFF & b 62 | for _ in range(0, 8): 63 | if (crc & 0x0001) ^ (cur_byte & 0x0001): 64 | crc = (crc >> 1) ^ poly 65 | else: 66 | crc >>= 1 67 | cur_byte >>= 1 68 | crc = (~crc & 0xFFFF) 69 | crc = (crc << 8) | ((crc >> 8) & 0xFF) 70 | 71 | return crc & 0xFFFF 72 | 73 | 74 | class ScpPrinter: 75 | """A simple printer class to print a pair of arguments""" 76 | def __init__(self): 77 | pass 78 | 79 | def p(self, p1, p2): 80 | print('{0} {1}'.format(str(p1).ljust(30), p2)) 81 | -------------------------------------------------------------------------------- /test_scpinfo.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # Test Code 4 | 5 | from scpreader import FileReader, ScpReader 6 | from scpformat import Section1TagsFormatter, PatientSexFormatter, PatientRaceFormatter 7 | 8 | def test_scp_record(): 9 | scp = read_scp() 10 | 11 | assert scp.has_section(0) 12 | assert scp.has_section(1) 13 | 14 | assert 34144 == scp.len, "SCP record length is wrong" 15 | assert 1643 == scp.crc, "SCP record checksum is wrong" 16 | 17 | assert scp.section(0), "No Section 0 in SCP record" 18 | 19 | def test_number_of_leads(): 20 | scp = read_scp() 21 | 22 | assert 12 == scp.number_of_leads(), "Section 3 should contain 12 leads" 23 | 24 | def test_section_header(): 25 | scp = read_scp() 26 | 27 | h = scp.section(0).h 28 | 29 | assert 0 == h.id, "Section 0 id should be 0" 30 | assert 136 == h.len, "Section 0 len should be 136" 31 | assert 20 == h.versnr, "Section 0 version number should be 20" 32 | assert 20 == h.protnr, "Section 0 protocol number should be 20" 33 | assert h.reserved 34 | 35 | 36 | def test_section0(): 37 | scp = read_scp() 38 | 39 | s0 = scp.section(0) 40 | assert s0 41 | for i in range (1,8): 42 | assert s0.has_section(i) 43 | assert not s0.has_section(12) 44 | 45 | p1 = s0.pointer_for_section(1) 46 | assert p1 47 | assert p1.section_has_data() 48 | 49 | def test_section1(): 50 | scp = read_scp() 51 | 52 | s1 = scp.section(1) 53 | assert s1 54 | assert 12 == len(s1.tags), "Section 1 should have 12 tags" 55 | assert 152 == s1.datalen 56 | 57 | def test_section1_tags(): 58 | scp = read_scp() 59 | 60 | s1 = scp.section(1) 61 | 62 | f = Section1TagsFormatter(s1.tags) 63 | 64 | assert 0 == s1.tags[0].tag 65 | assert 6 == s1.tags[0].len 66 | assert 12 == len(s1.tags) 67 | assert "Clark" == f.format_tag(0) 68 | assert "SBJ-123" == f.format_tag(2) 69 | assert "Male" == PatientSexFormatter(f.tag_data(8)).text 70 | assert "Caucasian" == PatientRaceFormatter(f.tag_data(9)).text 71 | 72 | assert 2 == s1.tags[1].tag 73 | assert 8 == s1.tags[1].len 74 | 75 | def test_section2(): 76 | scp = read_scp() 77 | 78 | s = scp.section(2) 79 | assert s.p.section_has_data() 80 | assert s.h.crc == 22179 81 | assert 19999 == s.nr_huffman_tables, "Section 2 (Nr of Huffman tables) should be 19999" 82 | 83 | def test_section3(): 84 | scp = read_scp() 85 | 86 | s3 = scp.section(3) 87 | assert s3 88 | assert s3.p.section_has_data() 89 | assert s3.h.crc == 45638 90 | assert 12 == s3.nrleads 91 | 92 | def test_section4(): 93 | scp = read_scp() 94 | 95 | s = scp.section(4) 96 | assert s.h.crc == 4777 97 | assert s.ref_beat_type_len == 1198 98 | 99 | def test_section6(): 100 | scp = read_scp() 101 | 102 | s = scp.section(6) 103 | assert s.h.crc == 61490 104 | assert s.h.len == 30084 105 | assert s.avm == 2500 106 | assert s.sample_time_interval == 2000 107 | assert len(s.nr_bytes_for_leads) == 12 108 | 109 | def test_section7(): 110 | scp = read_scp() 111 | 112 | s = scp.section(7) 113 | assert s.h.crc == 26535 114 | assert s.h.len == 242 115 | assert s.reference_count == 13 116 | 117 | def read_scp(): 118 | fr = FileReader('example/example.scp') 119 | scpReader = ScpReader(fr) 120 | scp = scpReader.read_scp() 121 | scpReader.close() 122 | return scp 123 | -------------------------------------------------------------------------------- /test_scputil.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # Test Code 4 | 5 | from scputil import * 6 | 7 | def test_lead_dic_keys(): 8 | d = lead_dic() 9 | for i in range(0,184): 10 | assert i in d 11 | 12 | def test_lead_dic_value(): 13 | d = lead_dic() 14 | assert d[23] == "LL" 15 | 16 | def test_crc16(): 17 | b = bytearray([0x13, 0x00, 0x00, 0x00, 0x08, 0x00]) 18 | checksum = crc16(b) 19 | assert checksum == 35581 20 | 21 | def test_b2i(): 22 | b = (978).to_bytes(2, byteorder='little') 23 | i = b2i(b) 24 | assert 978 == i 25 | --------------------------------------------------------------------------------