syncoder 0.0.0__tar.gz

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.

Potentially problematic release.


This version of syncoder might be problematic. Click here for more details.

@@ -0,0 +1,11 @@
1
+ __pycache__/*
2
+ Docs/
3
+ *.ipynb
4
+ *.dna
5
+ *.pdf
6
+ *.txt
7
+ *.json
8
+ *.zip
9
+ *.jpg
10
+ *.bin
11
+ debugging.py
@@ -0,0 +1,2 @@
1
+ #ignore this directory
2
+ *
syncoder-0.0.0/LICENSE ADDED
@@ -0,0 +1,9 @@
1
+ Copyright 2025 Chris Takahashi
2
+
3
+ Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
4
+
5
+ 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
6
+ 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
7
+ 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
8
+
9
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: syncoder
3
+ Version: 0.0.0
4
+ Summary: A codec for DNA data storage
5
+ Project-URL: Homepage, https://example.com/TBD
6
+ Project-URL: Issues, https://example.com/TBD/issues
7
+ Author-email: Christopher Takahashi <cnt@uw.edu>
8
+ License-Expression: BSD-3-Clause
9
+ License-File: LICENSE
10
+ Requires-Python: >=3.9
11
+ Description-Content-Type: text/markdown
12
+
13
+ Readme coming soon.
@@ -0,0 +1 @@
1
+ Readme coming soon.
@@ -0,0 +1,12 @@
1
+ name: codec-user
2
+ channels:
3
+ - conda-forge
4
+ dependencies:
5
+ - python=3.12
6
+ - numpy=2.*
7
+ - pytest
8
+ - reedsolo=1.7.*
9
+ - pip=24.*
10
+ - pip:
11
+ - galois==0.4.*
12
+
@@ -0,0 +1,19 @@
1
+ name: codec-dev
2
+ channels:
3
+ - conda-forge
4
+ dependencies:
5
+ - python=3.12
6
+ - numpy=2.*
7
+ - matplotlib=3.*
8
+ - pytest
9
+ - reedsolo=1.7.*
10
+ - pip=24.*
11
+ - sphinx=7.*
12
+ - sphinx_rtd_theme
13
+ - biopython
14
+ - jupyter
15
+ - ipykernel
16
+ - build
17
+ - pip:
18
+ - galois==0.4.*
19
+
@@ -0,0 +1,14 @@
1
+ name: codec-test
2
+ channels:
3
+ - conda-forge
4
+ dependencies:
5
+ - python=3.12
6
+ - numpy=2.*
7
+ - pytest
8
+ - reedsolo=1.7.*
9
+ - pip=24.*
10
+ - sphinx=7.*
11
+ - sphinx_rtd_theme
12
+ - pip:
13
+ - galois==0.4.*
14
+
syncoder-0.0.0/foo.py ADDED
@@ -0,0 +1,4 @@
1
+
2
+ from src import *
3
+
4
+ print(nextfn(5))
@@ -0,0 +1,22 @@
1
+ [project]
2
+ name = "syncoder"
3
+ version = "0.0.0"
4
+ description = "A codec for DNA data storage"
5
+ authors = [
6
+ { name="Christopher Takahashi", email="cnt@uw.edu" }
7
+ ]
8
+ readme = "README.md"
9
+ requires-python = ">=3.9"
10
+ license = "BSD-3-Clause"
11
+ license-files = ["LICENSE"]
12
+
13
+ [project.urls]
14
+ Homepage = "https://example.com/TBD"
15
+ Issues = "https://example.com/TBD/issues"
16
+
17
+ [tool.setuptools]
18
+ packages = ["syncoder"]
19
+
20
+ [build-system]
21
+ requires = ["hatchling >= 1.26"]
22
+ build-backend = "hatchling.build"
@@ -0,0 +1 @@
1
+ __all__ = ["bar.nextfn"]
@@ -0,0 +1,618 @@
1
+ import os
2
+ import struct
3
+ import itertools
4
+ import logging
5
+ from typing import Optional,Union, Callable #requires python 3.5 or later
6
+
7
+
8
+ import numpy as np
9
+ from numpy.typing import ArrayLike
10
+
11
+ import galois
12
+ import reedsolo
13
+
14
+ #### ROADMAP ####
15
+ # - integrate the baseN to DNA function here. (generalize from b32fn)
16
+ # - Strand prefix sub-encoder.
17
+ # - Sample objective functions (at least minimize homopolymers and avoid annealing.)
18
+
19
+
20
+ lipsum = b"Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, pretium quis, sem. Nulla consequat massa quis enim. Donec pede justo, fringilla vel, aliquet nec, vulputate eget, arcu. In enim justo, rhoncus ut, imperdiet a, venenatis vitae, justo. Nullam dictum felis eu pede mollis pretium. Integer tincidunt. Cras dapibus. Vivamus elementum semper nisi. Aenean vulputate eleifend tellus. Aenean leo ligula, porttitor eu, consequat vitae, eleifend ac, enim. Aliquam lorem ante, dapibus in, viverra quis, feugiat a, tellus. Phasellus viverra nulla ut metus varius laoreet. Quisque rutrum. Aenean imperdiet. Etiam ultricies nisi vel augue. Curabitur ullamcorper ultricies nisi. Nam eget dui. Etiam rhoncus. Maecenas tempus, tellus eget condimentum rhoncus, sem quam semper libero, sit amet adipiscing sem neque sed ipsum." #cspell:disable-line
21
+
22
+
23
+ def _int_to_baseN(n:int,base:int,length:int=-1)->list[int]:
24
+ """Converts an integer to a base N number in little endian order.
25
+
26
+ Args:
27
+ n (int): The integer to be converted.
28
+ base (int): The base of the number system.
29
+ length (int, optional): If specified, the number will be zero padded to this length.
30
+
31
+ Returns:
32
+ list[int]: The list of digits representing the base N number.
33
+
34
+ """
35
+
36
+ digits = []
37
+ while n>0:
38
+ n,r = divmod(n,base)
39
+ digits.append(r)
40
+
41
+ #do zero padding
42
+ while len(digits)<length:
43
+ digits.append(0)
44
+ return digits
45
+
46
+ def _baseN_to_int(digits: list[int], base: int) -> int:
47
+ """
48
+ Converts a base N number to an integer using little endian order.
49
+
50
+ Args:
51
+ digits (list[int]): The list of digits representing the base N number.
52
+ base (int): The base of the number.
53
+
54
+ Returns:
55
+ int: The converted integer value.
56
+
57
+ """
58
+
59
+ n = 0
60
+ for d in reversed(digits):
61
+ n = n*base + d
62
+ return n
63
+
64
+ def computeNStrands(nbytes:int,
65
+ inner_alphabet_size:int=32,
66
+ inner_d:int=5,
67
+ inner_n:Optional[int]=None,
68
+ n_redundant_strands:int=5)->int:
69
+
70
+ #TODO: do some algebra to solve for n_strands.
71
+ min_m=1
72
+ max_m=2**16-1
73
+ while min_m<max_m:
74
+ mid = (min_m+max_m)//2
75
+ codec = BaseNBlockCodec(inner_alphabet_size=inner_alphabet_size,
76
+ inner_d=inner_d,
77
+ inner_n=inner_n,
78
+ n_redundant_strands=n_redundant_strands,
79
+ n_strands=mid)
80
+ if codec.block_capacity_bytes < nbytes:
81
+ min_m = mid+1
82
+ else:
83
+ max_m = mid
84
+ return min_m
85
+ #index_bits = np.ceil(np.log2(n_strands)).astype(int)
86
+ #k = inner_n - (inner_d - 1)
87
+ #data_chunk_size = int(k*np.log2(inner_alphabet_size)-index_bits) // 8
88
+ #block_capacity_bytes = data_chunk_size*(n_strands - n_redundant_strands)
89
+
90
+
91
+
92
+
93
+ class BaseNBlockCodec:
94
+ def __init__(self,
95
+ inner_alphabet_size:int=32,
96
+ inner_d:int=5,
97
+ inner_n:Optional[int]=None,
98
+ n_redundant_strands:int=5,
99
+ n_strands:int=100,
100
+ max_strand_index:Optional[int]=None):
101
+ """
102
+ Initialize the Codec object.
103
+
104
+ Args:
105
+ inner_alphabet_size (int): Size of the inner alphabet (must be a prime power).
106
+ inner_d (int): Inner codeword distance.
107
+ inner_n (Optional[int]): Number of symbols in inner block (n < inner_alphabet_size). Default: inner_alphabet_size-1.
108
+ n_redundant_strands (int): Number of redundant strands.
109
+ n_strands (int): Number of "strands" stored by this codec including redundant strands.
110
+ max_strand_index (Optional[int]): Maximum guaranteed index. Default: n_strands.
111
+ """
112
+ #TODO: handle more than 255 total strands.
113
+ #this was formally a parameter but probably better to keep it fixed or automatically calculated.
114
+ if n_strands<=255:
115
+ self.outer_alphabet_size_bytes = 1
116
+ else:
117
+ self.outer_alphabet_size_bytes = 2
118
+ if n_strands>(2**(8*self.outer_alphabet_size_bytes)-1):
119
+ raise ValueError("Too many strands for outer code.")
120
+ self.n_strands = n_strands
121
+
122
+ self.n_message_strands = n_strands - n_redundant_strands
123
+
124
+ if inner_n is None:
125
+ inner_n = inner_alphabet_size - 1
126
+ else:
127
+ #TODO: if inner_n is requested to be too large, double up on inner blocks for a single "strand"
128
+ # make sure extra sequence numbers are not added to inner blocks. and or maybe add a
129
+ # parameter for the number of inner blocks per strand.
130
+ assert inner_n<inner_alphabet_size
131
+
132
+ self.inner_coder = galois.ReedSolomon(n=inner_alphabet_size-1,d=inner_d,field=galois.GF(inner_alphabet_size))
133
+ # in units of symbols (there are q=inner_alphabet_size possible symbols)
134
+ # ##################strand (n symbols)#######################
135
+ # # message # index # redundancy #
136
+ # |<----------------- k ------------------->|<---- d-1 ---->|
137
+ # |<----------------------- n ---------------------------->|
138
+ #
139
+ # sizes in bytes:
140
+ # |<---- self.data_chunk_size ----->|<- . ->|
141
+ # ^ = self.index_bits <-- in bits not bytes.
142
+ # note: actual k remains alphabet_size-1, but the code is shorted (0 padded) to inner_n symbols.
143
+ k = inner_n - (inner_d - 1)
144
+ q = self.inner_coder.field.order
145
+
146
+ #number of bytes needed to store the index
147
+ self.index_bits = np.ceil(np.log2(self.n_strands)).astype(int)
148
+ logging.debug("bits per strand used for indexing: {}".format(self.index_bits))
149
+
150
+ #message length in bytes (without index)
151
+ self.data_chunk_size = int(k*np.log2(q)-self.index_bits) // 8
152
+ logging.debug( "strand capacity in bytes: {}".format(self.data_chunk_size) )
153
+
154
+ _wasted_bits = k*np.log2(q) - (self.data_chunk_size*8+self.index_bits )
155
+ logging.debug( "{} bits wasted per strand".format( _wasted_bits ) )
156
+ logging.debug( "Maximum strand index (using wasted bits): {}".format(2**(int(self.index_bits+_wasted_bits))))
157
+ _wasted_symbols = _wasted_bits/np.log2(q)
158
+ if _wasted_symbols>=1:
159
+ logging.warning( "{} symbols wasted per strand. Consider decreasing inner_n.".format( _wasted_symbols) )
160
+ else:
161
+ logging.debug("{} symbols wasted per strand".format( _wasted_symbols))
162
+
163
+
164
+ self.block_capacity_bytes = self.data_chunk_size*(self.n_strands - n_redundant_strands)
165
+ logging.debug( "codec message size: {} bytes".format(self.block_capacity_bytes) )
166
+
167
+ #init the outer coder
168
+ self.outer_coder = reedsolo.RSCodec(n_redundant_strands, c_exp=8*self.outer_alphabet_size_bytes)
169
+
170
+ def encode(self,data:bytes,index_start:int=0)->list[list[int]]:
171
+ """ Encodes the given data using the outer and inner codes.
172
+
173
+ Args:
174
+ data (bytes): The input data to be encoded.
175
+ index_start (int, optional): The starting index for the encoded chunks. Defaults to 0.
176
+
177
+ Returns:
178
+ list[list[int]]: The encoded chunks of data.
179
+ """
180
+
181
+ #TODO: add option to put the index in multiples of inner symbols instead of bytes.
182
+ # this would give options to improve data packing depending on parameters.
183
+ # even better this could be autocomputed.
184
+ #TODO: consider putting outer code redundancy striped vertically bytewise so that
185
+ # lost strands with odd numbers of bytes don't erase bytes from neighboring strands.
186
+
187
+
188
+ #encode process outline:
189
+ # 0. serial data
190
+ # |--------------------------------... -----------|
191
+ # 1. strand length payload chunks (self.data_chunk_size in bytes)
192
+ # |<----------------- self.data_chunk_size ------------->|
193
+ # |<----------------- self.data_chunk_size ------------->|
194
+ # ...
195
+ # |<----------------- self.data_chunk_size ------------->|
196
+ # 2. outer code (r= outer code redundancy applied to columns)
197
+ # |<----------------- self.data_chunk_size ------------->|
198
+ # |<----------------- self.data_chunk_size ------------->|
199
+ # ...
200
+ # |<----------------- self.data_chunk_size ------------->|
201
+ # |<rrrrrrrrrrrrrrrrr self.data_chunk_size rrrrrrrrrrrrr>|
202
+ # ...
203
+ # |<rrrrrrrrrrrrrrrrr self.data_chunk_size rrrrrrrrrrrrr>|
204
+ # 3. inner code (R= inner code redundancy applied to rows, * = inner code symbol)
205
+ # |<***************** self.data_chunk_size *************RRRRRRRR>|
206
+ # |<***************** self.data_chunk_size *************RRRRRRRR>|
207
+ # ...
208
+ # |<***************** self.data_chunk_size *************RRRRRRRR>|
209
+ # |<rrrrrrrrrrrrrrrrr self.data_chunk_size rrrrrrrrrrrrrRRRRRRRR>|
210
+ # ...
211
+ # |<rrrrrrrrrrrrrrrrr self.data_chunk_size rrrrrrrrrrrrrRRRRRRRR>|
212
+
213
+ #1. Reshape data
214
+ logging.debug("applying outer code to {} bytes".format(len(data)))
215
+ # reshape data so columns can be coded independently.
216
+ if self.outer_coder.c_exp==8:
217
+ data_np = np.frombuffer(data,dtype=np.uint8)
218
+ assert len(data_np)%self.data_chunk_size == 0
219
+ data_np = data_np.reshape(-1,self.data_chunk_size)
220
+ elif self.outer_coder.c_exp==16:
221
+ data_np = np.frombuffer(data,dtype=np.uint16)
222
+ if self.data_chunk_size%2!=0:
223
+ raise ValueError("data_chunk_size must be even for 16 bit outer code.")
224
+ if len(data_np)%(self.data_chunk_size//2) != 0:
225
+ raise ValueError(f"data length(={len(data_np)}) must be multiple of data_chunk_size//2(={self.data_chunk_size//2})."+
226
+ " Consider padding data.")
227
+ data_np = data_np.reshape(-1,self.data_chunk_size//2)
228
+ else:
229
+ raise ValueError("Unsupported outer code byte size.")
230
+
231
+ #2. apply the outer code (encode columns of data)
232
+ data_enc_cols = np.array([self.outer_coder.encode(dc) for dc in data_np.transpose()],dtype=data_np.dtype)
233
+ data_enc_chunks = data_enc_cols.transpose()
234
+
235
+ logging.debug("outer coded strands: {}".format(data_enc_chunks.shape[0]))
236
+
237
+ #3. apply the inner code
238
+ chunks = []
239
+ index = index_start
240
+ _temp = 0
241
+ for chunk in data_enc_chunks:
242
+ #add index to "strand".
243
+ d = chunk.tobytes()
244
+ #use little endian to match x86/x64 byte order and avoid needing to pad byte string.
245
+ chunk = int.from_bytes(d,'little')
246
+ #add index to end (MS bits)
247
+ chunk += index<<(self.data_chunk_size*8)
248
+ index += 1
249
+ #but.. we still need to pad out the symbol stream.
250
+ chunk = _int_to_baseN(chunk,self.inner_coder.field.order,length=self.inner_coder.k)
251
+ chunks.append(chunk)
252
+
253
+ #apply inner code
254
+ ic_chunks= [self.inner_coder.encode(c).tolist() for c in chunks]
255
+
256
+ return ic_chunks
257
+
258
+ def decode(self, data: list[list[int]], index_start: int = 0) -> bytes:
259
+ """
260
+ Decodes the given data.
261
+
262
+ Args:
263
+ data (list[list[int]]): The encoded data to be decoded.
264
+ index_start (int, optional): The starting index for decoding. Defaults to 0.
265
+
266
+ Returns:
267
+ bytes: The decoded data.
268
+ outer erasures: list of erasures in outer code.
269
+ outer errors: list of errors in outer code.
270
+ inner errors: list of number of errors in inner code by strand index. -1 -> strand not present.
271
+
272
+ Raises:
273
+ None
274
+ """
275
+ # inner decode
276
+ ## TODO: handle erasures in inner code.
277
+ chunked_data = [self.inner_coder.decode(d,errors=True) for d in data]
278
+
279
+
280
+ # handle outer decode
281
+ ordered_chunks = [None] * self.n_strands
282
+ chunk_errors = [-1] * self.n_strands
283
+ for chunk in chunked_data:
284
+ chunk_int = _baseN_to_int(chunk[0].tolist(), self.inner_coder.field.order)
285
+ chunk_index = chunk_int >> (self.data_chunk_size * 8) - index_start
286
+ _mask = (2 ** (self.data_chunk_size * 8)) - 1
287
+ chunk_int = chunk_int & _mask
288
+ chunk_bytes = chunk_int.to_bytes(self.data_chunk_size, 'little')
289
+ #print(chunk_index)
290
+ try:
291
+ ordered_chunks[chunk_index] = chunk_bytes
292
+ chunk_errors[chunk_index] = chunk[1]
293
+ except IndexError:
294
+ logging.info("strand index out of bounds:" + str(chunk_index))
295
+
296
+ # identify erasures
297
+ erasures = []
298
+ for i, c in enumerate(ordered_chunks):
299
+ if c is None:
300
+ erasures.append(i) # remember where erasure is
301
+ ordered_chunks[i] = b'\x00' * self.data_chunk_size # fill in erasures with zeros
302
+
303
+ if self.outer_coder.c_exp==8:
304
+ outer_dtype = np.uint8
305
+ elif self.outer_coder.c_exp==16:
306
+ outer_dtype = np.uint16
307
+ else:
308
+ raise ValueError("Unsupported outer code byte size.")
309
+ data_np_encoded = np.array([np.frombuffer(c, dtype=outer_dtype) for c in ordered_chunks]).transpose()
310
+ data_decoded_results = [self.outer_coder.decode(dc, erase_pos=erasures) for dc in data_np_encoded]
311
+ data_decoded_chunks = np.array([d[0] for d in data_decoded_results],dtype=outer_dtype).transpose()
312
+ data_decoded = data_decoded_chunks.flatten().tobytes()
313
+
314
+ # report erasures (lost strands) in outer code
315
+ logging.info("erasures (lost) chunks: {}".format(erasures))
316
+ data_errata_col_pos = [e[2] for e in data_decoded_results]
317
+
318
+ errors = []
319
+ for i, pos in enumerate(data_errata_col_pos):
320
+ for strand in pos:
321
+ if (not(strand in erasures)):
322
+ errors.append((strand, i))
323
+ errors.sort(key=lambda x: x[0])
324
+
325
+ # unlikely to find an error since it would have to pass the inner code.
326
+ for e in errors:
327
+ if e[0] in erasures:
328
+ pass
329
+ else:
330
+ logging.warning("Outer code errors found on strand {}, byte {}.".format(*e))
331
+
332
+ return data_decoded, erasures, errors, chunk_errors
333
+
334
+
335
+ #convert base N into DNA.
336
+ _default_b32_alphabet = ['GCT', 'ACT', 'AGT', 'CTC', 'TGC', 'GAG', 'GCA', 'ATG', 'AGA', 'AGC', 'CAC', 'CAT', 'CAG', 'CTG', 'TCT', 'GAC', 'GTC', 'GTG', 'ACA', 'ACG', 'ATC', 'CTA', 'CGA', 'CGT', 'TAC', 'TAG', 'TCA', 'TCG', 'TGA', 'TGT', 'GAT', 'GTA']
337
+ _default_b32_alphabet_alt = ['ATA', 'AAC', 'CCT', 'TAT', 'CCG', 'TGG', 'CGC', 'CGG', 'GTT', 'GGT', 'GCG', 'TCC', 'CCA', 'GGA', 'ACC', 'AGG', 'TAA', 'GCC', 'AAA', 'CCC', 'GGG', 'GGC', 'TTA', 'TTT', 'GAA', 'AAG', 'CTT', 'TTG', 'TTC', 'AAT', 'CAA', 'ATT']
338
+
339
+ def find_avoid(seq:str, avoids:list)->tuple[int]:
340
+ """find index and length of avoided sequence """
341
+ for a in avoids:
342
+ loc = seq.find(a)
343
+ if loc>0:
344
+ return loc,len(a)
345
+ return -1,0
346
+
347
+ def longest_match(a:str,b:str):
348
+ def _longest_match(a:str,b:str):
349
+ max_match = 0
350
+ for i in range(len(a)):
351
+ match = 0
352
+ for offset in range(min(len(a)-i,len(b))):
353
+ if a[i+offset] == b[offset]:
354
+ match +=1
355
+ else:
356
+ match = 0
357
+ if match > max_match:
358
+ max_match = match
359
+ return max_match
360
+ A = a.upper()
361
+ B = b.upper()
362
+ return max(_longest_match(A,B),_longest_match(B,A))
363
+
364
+ #Returns the reverse complement of a DNA sequence.
365
+ def reverse_complement(dna_sequence: str) -> str:
366
+ complement = {'A': 'T', 'T': 'A', 'C': 'G', 'G': 'C', 'a': 't', 't': 'a', 'c': 'g', 'g': 'c'}
367
+ reversed_sequence = dna_sequence[::-1]
368
+ reverse_complement_sequence = ''.join(complement[nucleotide] for nucleotide in reversed_sequence)
369
+ return reverse_complement_sequence
370
+
371
+ def longest_binder(a:str,b:str):
372
+ A = reverse_complement(a)
373
+ return longest_match(A,b)
374
+
375
+
376
+
377
+ def b32_to_DNA_optimize(file_data:list[list[int]],words:list[str], alternate_words:list[str], mask:Union[list[int],None]=None, penalty_fn:Union[Callable[[str],int],None]=None)->list[str]:
378
+ """
379
+ TODO: rename to bN_to_DNA_optimize, and add test.
380
+ mask: list of ints. 0 for word, 1 for alternate word, -1 for don't care. None for no mask.
381
+ penalty_fn: function that takes a DNA sequence and returns a score. penalty_fn(DNA_seq:str)->int
382
+ """
383
+ return [b32_to_DNA_optimize_single(x,words,alternate_words,penalty_fn=penalty_fn) for x in file_data]
384
+
385
+
386
+ def b32_to_DNA_optimize_single(strand_data:list[int],words:list[str], alternate_words:list[str], mask:Union[list[int],None]=None ,penalty_fn=None)->str:
387
+ """
388
+ TODO: rename to bN_to_DNA_optimize_single
389
+ TODO: add mask support.
390
+ mask: list of ints. 0 for word, 1 for alternate word, -1 for don't care. None for no mask.
391
+ """
392
+ if mask is not None:
393
+ mutable_ind = np.where(np.array(mask)==-1)
394
+ #raise NotImplementedError("mask not implemented yet")
395
+ else:
396
+ mutable_ind = np.arange(len(strand_data))
397
+ mask = mask.copy()
398
+ mask[mutable_ind] = 0
399
+
400
+ strand_data = np.array(strand_data)
401
+ words = np.array(list(zip(words,alternate_words)),dtype="S")
402
+ picks = np.zeros(len(strand_data),dtype=np.uint8)
403
+ picks[0:len(mask)] = mask
404
+ dna_seq = b"".join(words[strand_data,picks])
405
+ if penalty_fn is None:
406
+ return dna_seq,0
407
+ score = penalty_fn(dna_seq)
408
+
409
+ #greedy search, stop when you can't find a better solution one step away.
410
+ icount = 0
411
+ while True:
412
+ iteration_best_picks = picks.copy()
413
+ iteration_best_score = score
414
+ iteration_dna_seq = dna_seq
415
+ for i in mutable_ind:
416
+ new_picks = picks.copy()
417
+ new_picks[i] = not new_picks[i]
418
+ new_dna_seq = b"".join(words[strand_data,new_picks])
419
+ new_score = penalty_fn(new_dna_seq)
420
+ if new_score < iteration_best_score:
421
+ iteration_best_picks = new_picks
422
+ iteration_dna_seq = new_dna_seq
423
+ iteration_best_score = new_score
424
+ if iteration_dna_seq == dna_seq:
425
+ break
426
+ if iteration_best_score < score:
427
+ picks = iteration_best_picks
428
+ dna_seq = iteration_dna_seq
429
+ score = iteration_best_score
430
+ icount += 1
431
+ logging.debug(f"iteration {icount} picks: {iteration_best_picks}")
432
+
433
+
434
+ return dna_seq,score
435
+
436
+ def b32_to_DNA(file_data:list[list[int]],words:list[str], alternate_words:list[str], avoid_seq:list[str] = [])->list[str]:
437
+ """ with avoids, TODO: depricate """
438
+ dna = []
439
+ for strand in file_data:
440
+ _t = [words[x] for x in strand]
441
+ _s = "".join(_t)
442
+ ind,l = find_avoid(_s,avoid_seq)
443
+ _last_s = ""
444
+ while ind>0:
445
+ print("here")
446
+ if ind%3==0:
447
+ rep_index =ind//3
448
+ else:
449
+ rep_index = ind//3+1
450
+ _t[rep_index] = alternate_words[strand[rep_index]]
451
+ _last_s = _s
452
+ _s= "".join(_t)
453
+ if _last_s == _s:
454
+ raise Exception("Can't find solution")
455
+
456
+ ind,l = find_avoid(_s,avoid_seq)
457
+ dna.append(_s)
458
+ return dna
459
+
460
+ def dna_to_bN(dna: Union[list[str],list[bytes]] ,words:list[str], alternate_words:list[str])->list[int]:
461
+ """ takes a list of strings and converts them to a list of base N ints"""
462
+
463
+ if isinstance(dna[0],bytes):
464
+ dna = [x.decode() for x in dna]
465
+
466
+ wordlen = len(words[0])
467
+ #build the reverse lookup table
468
+ bNlut = {}
469
+ for ind in range(len(words)):
470
+ bNlut[words[ind]] = ind
471
+ bNlut[alternate_words[ind]] = ind
472
+
473
+
474
+ bNdatalist = []
475
+ for d in dna:
476
+ bNdata = []
477
+ for ind in range(0,len(d),wordlen):
478
+ w = d[ind:ind+wordlen]
479
+ bNdata.append(bNlut[w])
480
+ bNdatalist.append(bNdata)
481
+ return bNdatalist
482
+
483
+ def dna_to_b32(dna: Union[list[str],list[bytes]] ,words:list[str], alternate_words:list[str])->list[int]:
484
+ assert len(words) == 32
485
+ return dna_to_bN(dna,words,alternate_words)
486
+
487
+
488
+ def __is_pow_two(n: int):
489
+ """Returns if a number is a power of two."""
490
+ return (n > 0) and ((n & ( ~(n-1) )) == n)
491
+
492
+ def dna_to_bytes(dna_seq: str, words: list[str]=_default_b32_alphabet, alternate_words: list[str]=_default_b32_alphabet) -> tuple[bytes, list[int]]:
493
+ """Convert a DNA sequence to bytes with zero padding.
494
+
495
+ This function takes a DNA sequence and converts it into a byte representation.
496
+ It assumes the alphabet length and base are the same.
497
+ The function ensures that the DNA sequence is properly decoded into bytes, with
498
+ zero padding applied as necessary to align the data.
499
+
500
+ Args:
501
+ dna_seq (str): The DNA sequence to be converted.
502
+ words (list[str]): The primary set of base words used, in order, for encoding.
503
+ alternate_words (list[str]): The alternate (aka synonymous) set of words used, in order, for encoding.
504
+
505
+ Returns:
506
+ bytes: The byte representation of the DNA sequence.
507
+ list[int]: a mask for alternate words. 0 for word, 1 for alternate word, -1 for don't care.
508
+ """
509
+ if len(words) != len(alternate_words):
510
+ raise ValueError("words and alternate_words must be the same length")
511
+ if len(words) < 2:
512
+ raise ValueError("words and alternate_words must be at least 2 long")
513
+ if len(dna_seq) % len(words[0]) != 0:
514
+ raise ValueError("DNA sequence length must be a multiple of the word length")
515
+
516
+ base = len(words)
517
+ word_len = len(words[0])
518
+
519
+ #create a mask for the DNA sequence
520
+ mask = [0]*(len(dna_seq)//word_len)
521
+ for i in range(len(mask)):
522
+ word = dna_seq[i*word_len:(i+1)*word_len]
523
+ if word in words:
524
+ mask[i] = 0
525
+ elif word in alternate_words:
526
+ mask[i] = 1
527
+ else:
528
+ raise ValueError("DNA sequence contains invalid word: {}".format(word))
529
+
530
+ #convert the DNA sequence to base N
531
+ bN_seq = dna_to_bN([dna_seq], words, alternate_words)[0]
532
+ int_value = _baseN_to_int(bN_seq, base)
533
+ num_words = len(dna_seq)/len(words[0])
534
+ num_bits = num_words * np.log2(base)
535
+ if __is_pow_two(base):
536
+ num_bits = int(np.round(num_bits)) #just incase log2 returns with a rounding error.
537
+ else:
538
+ num_bits = int(np.ceil(num_bits))
539
+ num_bytes = (num_bits + 7) // 8 #only works if num_bits is an int
540
+
541
+ return int_value.to_bytes(num_bytes, 'little'), mask
542
+
543
+ def chunk(b:bytes|bytearray,size:int):
544
+ """break into list of size sized chunks"""
545
+ chunks = []
546
+ for i in range(0, len(b), size):
547
+ chunks.append( b[i:i + size] )
548
+ return chunks
549
+
550
+ def insert_bytes(data:bytes, insert:bytes, chunk_len:int, num_inserts:int) -> bytes:
551
+ """Insert bytes into a byte array num_inserts times at chunk_len intervals. i.e.
552
+ the returned bytes will have the insert bytes every chunk_len bytes.
553
+
554
+ Args:
555
+ data (bytes): The original byte array.
556
+ insert (bytes): The bytes to be inserted.
557
+ chunk_len (int): The length of each chunk.
558
+ num_inserts (int): The number of insertions to be made.
559
+
560
+ Returns:
561
+ bytes: A list of byte chunks with the inserted bytes.
562
+
563
+ See also:
564
+ remove_bytes: The inverse of this function.
565
+ """
566
+ data = bytearray(data)
567
+ for i in range(num_inserts):
568
+ ins_index = i * chunk_len
569
+ data[ins_index:ins_index] = insert
570
+ return bytes(data)
571
+
572
+ def pad_data(data:bytes,block_size:int,padding_data:bytes|str = "random") -> bytes:
573
+ """Pad data to a multiple of block_size. The padding is added to the end of the data.
574
+
575
+ Args:
576
+ data (bytes): bytes object to be padded.
577
+ block_size (int): The block size to pad to. (return bytes length % block_size == 0)
578
+ padding_data (bytes|str, optional): The padding data to be added. Defaults to "random".
579
+ Returns:
580
+ bytes: The padded bytes.
581
+ """
582
+ padding_size = block_size - len(data) % block_size
583
+ if isinstance(padding_data,bytes):
584
+ if len(padding_data) < padding_size:
585
+ raise ValueError("padding data is too small")
586
+ if isinstance(padding_data,str):
587
+ if padding_data == "random":
588
+ padding_data = np.random.bytes(padding_size)
589
+ else:
590
+ raise ValueError("padding data must be bytes or 'random'")
591
+ return data + padding_data[:padding_size]
592
+
593
+ def remove_bytes(data:bytes, insert:int|bytes, chunk_len:int, num_inserts:int) -> bytes:
594
+ """Remove bytes from a byte array at specified intervals. This is the inverse of insert_bytes.
595
+
596
+ Args:
597
+ data (bytes): The original byte array.
598
+ insert_len (int|bytes): The length of the inserted bytes to be removed. If insert_len
599
+ is bytes, its length will be used, and the contents of the deleted bytes will be checked.
600
+ num_inserts (int): The number of insertions to be removed.
601
+
602
+ Returns:
603
+ bytes: The modified byte array with the inserted bytes removed.
604
+ """
605
+ if isinstance(insert,bytes):
606
+ insert_len = len(insert)
607
+ else:
608
+ insert = None
609
+
610
+ data = bytearray(data)
611
+ for i in reversed(range(num_inserts)):
612
+ ins_index = i * chunk_len
613
+ if insert is not None:
614
+ if data[ins_index:ins_index + insert_len] != insert:
615
+ raise ValueError("insert bytes do not match bytes to be removed")
616
+ del data[ins_index:ins_index + insert_len]
617
+ return bytes(data)
618
+
@@ -0,0 +1,80 @@
1
+ #write a test for codec.py
2
+
3
+ import random
4
+ import numpy as np
5
+ from itertools import product
6
+
7
+ import codec
8
+
9
+ #logging.basicConfig(level=logging.DEBUG,stream=sys.stdout)
10
+ #numba_logger = logging.getLogger('numba')
11
+ #numba_logger.setLevel(logging.WARNING)
12
+
13
+ def test_encode_decode():
14
+ c = codec.BaseNBlockCodec(inner_alphabet_size=32,inner_d=5,inner_n=30)
15
+
16
+ in_text = (codec.lipsum + codec.lipsum)[0:c.block_capacity_bytes]
17
+
18
+ coded = c.encode( in_text )
19
+ random.shuffle(coded)
20
+ #coded = coded[:-4] #erase a random strands
21
+ corrupt_index = random.randint(0,len(coded[0]))
22
+ coded[0][corrupt_index] = 6
23
+
24
+ out_text = c.decode( coded )[0]
25
+ assert in_text == out_text
26
+
27
+ def test_coded_to_bases():
28
+ #TODO: test optimize too
29
+
30
+ test_encoded_data = np.random.randint(0,32,(10,31))
31
+ DNA_with_scores = codec.b32_to_DNA_optimize(test_encoded_data,codec._default_b32_alphabet,codec._default_b32_alphabet_alt)
32
+ DNA = [x[0] for x in DNA_with_scores]
33
+ test_decoded_data = codec.dna_to_b32(DNA,codec._default_b32_alphabet,codec._default_b32_alphabet_alt)
34
+ print(test_encoded_data.flatten())
35
+ print( np.array(test_decoded_data).flatten())
36
+ assert np.array_equal( test_encoded_data.flatten(), np.array(test_decoded_data).flatten() )
37
+
38
+
39
+ def test_dna_to_bN():
40
+ base = 47
41
+ length = 4 # length>= log4(2*base)
42
+ allwords = list(product("ACTG", repeat=length))
43
+ allwords = [''.join(w) for w in allwords]
44
+ words = allwords[:base]
45
+ altwords = allwords[base:(2*base)]
46
+ data_seq = np.random.randint(0,base*2,(10,base-1))
47
+ data = np.mod(data_seq,base)
48
+
49
+ #"encode"
50
+ lut = np.array(words+altwords)
51
+ seqs = [lut[d] for d in data_seq]
52
+ seqs = [''.join(seq) for seq in seqs]
53
+ decode_data = np.array(codec.dna_to_bN(seqs,words,altwords))
54
+ assert np.array_equal(data, decode_data)
55
+
56
+ def test_dna_to_bytes():
57
+ #TODO: adapt to workwith not b32 too.
58
+ #answer = [64, 79, 231, 126, 228, 124, 3]
59
+ alphabet =codec._default_b32_alphabet
60
+ alphabet_alt = codec._default_b32_alphabet_alt
61
+ full_alphabet = np.array(alphabet + alphabet_alt)
62
+
63
+ #generate some DNA
64
+ _t = np.random.randint(0,2*len(alphabet),size=3*10)
65
+ _t_dna = full_alphabet[_t]
66
+ DNA = ''.join(_t_dna)
67
+ #DNA = "GCTCTTCCCACCACCATTGCCTTCTTCTTG"
68
+
69
+ answer32 = codec.dna_to_bN([DNA],alphabet,alphabet_alt)[0]
70
+
71
+ dna_bytes, mask = codec.dna_to_bytes(DNA,alphabet,alphabet_alt)
72
+
73
+ dna_baseN = codec._int_to_baseN(int.from_bytes(dna_bytes,"little"),len(alphabet))
74
+ assert dna_baseN == answer32
75
+
76
+ _t = np.array(dna_baseN)
77
+ dna_baseN_with_alt = _t + len(alphabet)*np.array(mask)
78
+ generated_dna = "".join(full_alphabet[dna_baseN_with_alt])
79
+
80
+ assert generated_dna == DNA