4e89c3ff36ed0087b20c2cf8d6005a4f959530c0
[jalview.git] / src / jalview / io / ScoreMatrixFile.java
1 package jalview.io;
2
3 import jalview.analysis.scoremodels.ScoreMatrix;
4 import jalview.analysis.scoremodels.ScoreModels;
5 import jalview.datamodel.SequenceI;
6
7 import java.io.IOException;
8 import java.util.StringTokenizer;
9
10 /**
11  * A class that can parse a file containing a substitution matrix and register
12  * it for use in Jalview
13  * <p>
14  * Accepts 'NCBI' format (e.g.
15  * https://www.ncbi.nlm.nih.gov/Class/FieldGuide/BLOSUM62.txt), with the
16  * addition of a header line to provide a matrix name, e.g.
17  * 
18  * <pre>
19  * ScoreMatrix BLOSUM62
20  * </pre>
21  * 
22  * Also accepts 'AAindex' format (as described at
23  * http://www.genome.jp/aaindex/aaindex_help.html) with the minimum data
24  * required being
25  * 
26  * <pre>
27  * H accession number (used as score matrix identifier in Jalview)
28  * D description (used for tooltip in Jalview)
29  * M rows = symbolList
30  * and the substitution scores
31  * </pre>
32  */
33 public class ScoreMatrixFile extends AlignFile implements
34         AlignmentFileReaderI
35 {
36   // first non-comment line identifier - also checked in IdentifyFile
37   public static final String SCOREMATRIX = "SCOREMATRIX";
38
39   private static final String DELIMITERS = " ,\t";
40
41   private static final String COMMENT_CHAR = "#";
42
43   private String matrixName;
44
45   boolean lowerDiagonalOnly;
46
47   /**
48    * Constructor
49    * 
50    * @param source
51    * @throws IOException
52    */
53   public ScoreMatrixFile(FileParse source) throws IOException
54   {
55     super(false, source);
56   }
57
58   @Override
59   public String print(SequenceI[] sqs, boolean jvsuffix)
60   {
61     return null;
62   }
63
64   /**
65    * Parses the score matrix file, and if successful registers the matrix so it
66    * will be shown in Jalview menus. This method is not thread-safe (a separate
67    * instance of this class should be used by each thread).
68    */
69   @Override
70   public void parse() throws IOException
71   {
72     ScoreMatrix sm = parseMatrix();
73
74     ScoreModels.getInstance().registerScoreModel(sm);
75   }
76
77   /**
78    * Parses the score matrix file and constructs a ScoreMatrix object. If an
79    * error is found in parsing, it is thrown as FileFormatException. Any
80    * warnings are written to syserr.
81    * 
82    * @return
83    * @throws IOException
84    */
85   public ScoreMatrix parseMatrix() throws IOException
86   {
87     ScoreMatrix sm = null;
88     int lineNo = 0;
89     String name = null;
90     char[] alphabet = null;
91     float[][] scores = null;
92     int size = 0;
93     int row = 0;
94     String err = null;
95     String data;
96     lowerDiagonalOnly = false;
97
98     while ((data = nextLine()) != null)
99     {
100       lineNo++;
101       data = data.trim();
102       if (data.startsWith(COMMENT_CHAR) || data.length() == 0)
103       {
104         continue;
105       }
106       if (data.toUpperCase().startsWith(SCOREMATRIX))
107       {
108         /*
109          * Parse name from ScoreMatrix <name>
110          * we allow any delimiter after ScoreMatrix then take the rest of the line
111          */
112         if (name != null)
113         {
114           System.err
115                   .println("Warning: 'ScoreMatrix' repeated in file at line "
116                           + lineNo);
117         }
118         StringTokenizer nameLine = new StringTokenizer(data, DELIMITERS);
119         if (nameLine.countTokens() < 2)
120         {
121           err = "Format error: expected 'ScoreMatrix <name>', found '"
122                   + data + "' at line " + lineNo;
123           throw new FileFormatException(err);
124         }
125         nameLine.nextToken(); // 'ScoreMatrix'
126         name = nameLine.nextToken(); // next field
127         name = data.substring(1).substring(data.substring(1).indexOf(name));
128         continue;
129       }
130       else if (data.startsWith("H ") && name == null)
131       {
132         /*
133          * AAindex identifier 
134          */
135         return parseAAIndexFormat(lineNo, data);
136       }
137       else if (name == null)
138       {
139         err = "Format error: 'ScoreMatrix <name>' should be the first non-comment line";
140         throw new FileFormatException(err);
141       }
142
143       /*
144        * next non-comment line after ScoreMatrix should be the 
145        * column header line with the alphabet of scored symbols
146        */
147       if (alphabet == null)
148       {
149         StringTokenizer columnHeadings = new StringTokenizer(data,
150                 DELIMITERS);
151         size = columnHeadings.countTokens();
152         alphabet = new char[size];
153         int col = 0;
154         while (columnHeadings.hasMoreTokens())
155         {
156           alphabet[col++] = columnHeadings.nextToken().charAt(0);
157         }
158         scores = new float[size][];
159         continue;
160       }
161
162       /*
163        * too much information
164        */
165       if (row >= size)
166       {
167         err = "Unexpected extra input line in score model file: '" + data
168                 + "'";
169         throw new FileFormatException(err);
170       }
171
172       parseValues(data, lineNo, scores, row, alphabet);
173       row++;
174     }
175
176     /*
177      * out of data - check we found enough
178      */
179     if (row < size)
180     {
181       err = String
182               .format("Expected %d rows of score data in score matrix but only found %d",
183                       size, row);
184       throw new FileFormatException(err);
185     }
186
187     /*
188      * If we get here, then name, alphabet and scores have been parsed successfully
189      */
190     sm = new ScoreMatrix(name, alphabet, scores);
191     matrixName = name;
192
193     return sm;
194   }
195
196   /**
197    * Parse input as AAIndex format, starting from the header line with the
198    * accession id
199    * 
200    * @param lineNo
201    * @param data
202    * @return
203    * @throws IOException
204    */
205   protected ScoreMatrix parseAAIndexFormat(int lineNo, String data)
206           throws IOException
207   {
208     String name = data.substring(2).trim();
209     String description = null;
210
211     float[][] scores = null;
212     char[] alphabet = null;
213     int row = 0;
214     int size = 0;
215
216     while ((data = nextLine()) != null)
217     {
218       lineNo++;
219       data = data.trim();
220       if (skipAAindexLine(data))
221       {
222         continue;
223       }
224       if (data.startsWith("D "))
225       {
226         description = data.substring(2).trim();
227       }
228       else if (data.startsWith("M "))
229       {
230         alphabet = parseAAindexRowsColumns(lineNo, data);
231         size = alphabet.length;
232         scores = new float[size][size];
233       }
234       else if (scores == null)
235       {
236         throw new FileFormatException(
237                 "No alphabet specified in matrix file");
238       }
239       else if (row >= size)
240       {
241         throw new FileFormatException("Too many data rows in matrix file");
242       }
243       else
244       {
245         parseValues(data, lineNo, scores, row, alphabet);
246         row++;
247         if (row == size)
248         {
249           break;
250         }
251       }
252     }
253     if (data != null)
254     {
255       System.err.println("Warning: unexpected extra data in matrix file: "
256               + data);
257     }
258
259     ScoreMatrix sm = new ScoreMatrix(name, alphabet, scores);
260     sm.setDescription(description);
261     matrixName = name;
262
263     return sm;
264   }
265
266   /**
267    * Parse one row of score values, delimited by whitespace or commas. The line
268    * may optionally include the symbol from which the scores are defined. Values
269    * may be present for all columns, or only up to the diagonal (in which case
270    * upper diagonal values are set symmetrically).
271    * 
272    * @param data
273    *          the line to be parsed
274    * @param lineNo
275    * @param scores
276    *          the score matrix to add data to
277    * @param row
278    *          the row number / alphabet index position
279    * @param alphabet
280    * @return
281    * @throws exception
282    *           if invalid, or too few, or too many values
283    */
284   protected void parseValues(String data, int lineNo, float[][] scores,
285           int row, char[] alphabet) throws FileFormatException
286   {
287     String err;
288     int size = alphabet.length;
289     StringTokenizer scoreLine = new StringTokenizer(data, DELIMITERS);
290
291     int tokenCount = scoreLine.countTokens();
292     if (tokenCount == size + 1)
293     {
294       /*
295        * check 'guide' symbol is the row'th letter of the alphabet
296        */
297       String symbol = scoreLine.nextToken();
298       if (symbol.length() > 1 || symbol.charAt(0) != alphabet[row])
299       {
300         err = String
301                 .format("Error parsing score matrix at line %d, expected '%s' but found '%s'",
302                         lineNo, alphabet[row], symbol);
303         throw new FileFormatException(err);
304       }
305     }
306
307     tokenCount = scoreLine.countTokens();
308
309     /*
310      * AAIndex format only has the lower diagonal i.e.
311      * 1 score in row 0, 2 in row 1, etc
312      * check this in all but the last row (which is the same either way)
313      */
314     if (row < size - 1)
315     {
316       boolean lowerDiagonal = tokenCount == row + 1;
317       if (lowerDiagonalOnly && !lowerDiagonal)
318       {
319         /*
320          * had detected lower diagonal form but now it isn't - error
321          */
322         err = String.format("Unexpected number of tokens at line %d",
323                 lineNo);
324         throw new FileFormatException(err);
325       }
326       lowerDiagonalOnly = lowerDiagonal;
327     }
328
329     if (!lowerDiagonalOnly && tokenCount != size)
330     {
331       err = String.format("Expected %d scores at line %d but found %d",
332               size, lineNo, scoreLine.countTokens());
333       throw new FileFormatException(err);
334     }
335     scores[row] = new float[size];
336     int col = 0;
337     String value = null;
338     while (scoreLine.hasMoreTokens())
339     {
340       try
341       {
342         value = scoreLine.nextToken();
343         scores[row][col] = Float.valueOf(value);
344         if (lowerDiagonalOnly)
345         {
346           scores[col][row] = scores[row][col];
347         }
348         col++;
349       } catch (NumberFormatException e)
350       {
351         err = String.format(
352                 "Invalid score value '%s' at line %d column %d", value,
353                 lineNo, col);
354         throw new FileFormatException(err);
355       }
356     }
357   }
358
359   /**
360    * Parse the line in an aaindex file that looks like
361    * 
362    * <pre>
363    * M rows = ARNDCQEGHILKMFPSTWYV, cols = ARNDCQEGHILKMFPSTWYV
364    * </pre>
365    * 
366    * rejecting it if rows and cols do not match. Returns the string of
367    * characters in the row/cols alphabet.
368    * 
369    * @param lineNo
370    * @param data
371    * @return
372    * @throws FileFormatException
373    */
374   protected char[] parseAAindexRowsColumns(int lineNo, String data)
375           throws FileFormatException
376   {
377     String err = "Unexpected aaIndex score matrix data at line " + lineNo
378             + ": " + data;
379     
380     try
381     {
382       String[] toks = data.split(",");
383       String rowsAlphabet = toks[0].split("=")[1].trim();
384       String colsAlphabet = toks[1].split("=")[1].trim();
385       if (!rowsAlphabet.equals(colsAlphabet))
386       {
387         throw new FileFormatException("rows != cols");
388       }
389       return rowsAlphabet.toCharArray();
390     } catch (Throwable t)
391     {
392       throw new FileFormatException(err + " " + t.getMessage());
393     }
394   }
395
396   /**
397    * Answers true if line is one we are not interested in from AAindex format
398    * file
399    * 
400    * @param data
401    * @return
402    */
403   protected boolean skipAAindexLine(String data)
404   {
405     if (data.startsWith(COMMENT_CHAR) || data.length() == 0)
406     {
407       return true;
408     }
409     if (data.startsWith("*") || data.startsWith("R ")
410             || data.startsWith("A ") || data.startsWith("T ")
411             || data.startsWith("J ") || data.startsWith("//"))
412     {
413       return true;
414     }
415     return false;
416   }
417
418   /**
419    * Answers true if the data line consists of the alphabet characters,
420    * delimited (as to provide a heading row). Otherwise returns false (e.g. if
421    * the data is a row of score values).
422    * 
423    * @param data
424    * @param alphabet
425    * @return
426    */
427   private boolean isHeaderLine(String data, String alphabet)
428   {
429     StringTokenizer scoreLine = new StringTokenizer(data, DELIMITERS);
430     int i = 0;
431     while (scoreLine.hasMoreElements())
432     {
433       /*
434        * skip over characters in the alphabet that are 
435        * also a delimiter (e.g. space)
436        */
437       char symbol = alphabet.charAt(i++);
438       if (!DELIMITERS.contains(String.valueOf(symbol)))
439       {
440         if (!String.valueOf(symbol).equals(scoreLine.nextToken()))
441         {
442           return false;
443         }
444       }
445     }
446     return true;
447   }
448
449   public String getMatrixName()
450   {
451     return matrixName;
452   }
453 }