annotate data/fileio/CSVFileReader.cpp @ 1488:53fa8d57b728 import-audio-data

Add wave model as possible target for CSV import
author Chris Cannam
date Thu, 28 Jun 2018 14:49:46 +0100
parents 48e9f538e6e9
children 8d4f09552ba4
rev   line source
Chris@148 1 /* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */
Chris@148 2
Chris@148 3 /*
Chris@148 4 Sonic Visualiser
Chris@148 5 An audio file viewer and annotation editor.
Chris@148 6 Centre for Digital Music, Queen Mary, University of London.
Chris@148 7 This file copyright 2006 Chris Cannam.
Chris@148 8
Chris@148 9 This program is free software; you can redistribute it and/or
Chris@148 10 modify it under the terms of the GNU General Public License as
Chris@148 11 published by the Free Software Foundation; either version 2 of the
Chris@148 12 License, or (at your option) any later version. See the file
Chris@148 13 COPYING included with this distribution for more information.
Chris@148 14 */
Chris@148 15
Chris@148 16 #include "CSVFileReader.h"
Chris@148 17
Chris@150 18 #include "model/Model.h"
Chris@148 19 #include "base/RealTime.h"
Chris@631 20 #include "base/StringBits.h"
Chris@148 21 #include "model/SparseOneDimensionalModel.h"
Chris@148 22 #include "model/SparseTimeValueModel.h"
Chris@152 23 #include "model/EditableDenseThreeDimensionalModel.h"
Chris@628 24 #include "model/RegionModel.h"
Chris@897 25 #include "model/NoteModel.h"
Chris@1488 26 #include "model/WritableWaveFileModel.h"
Chris@308 27 #include "DataFileReaderFactory.h"
Chris@148 28
Chris@148 29 #include <QFile>
Chris@1030 30 #include <QFileInfo>
Chris@148 31 #include <QString>
Chris@148 32 #include <QRegExp>
Chris@148 33 #include <QStringList>
Chris@148 34 #include <QTextStream>
Chris@148 35
Chris@148 36 #include <iostream>
Chris@628 37 #include <map>
Chris@1428 38 #include <string>
Chris@148 39
Chris@1113 40 using namespace std;
Chris@1113 41
Chris@392 42 CSVFileReader::CSVFileReader(QString path, CSVFormat format,
Chris@1047 43 sv_samplerate_t mainModelSampleRate) :
Chris@392 44 m_format(format),
Chris@1009 45 m_device(0),
Chris@1009 46 m_ownDevice(true),
Chris@631 47 m_warnings(0),
Chris@148 48 m_mainModelSampleRate(mainModelSampleRate)
Chris@148 49 {
Chris@1009 50 QFile *file = new QFile(path);
Chris@148 51 bool good = false;
Chris@148 52
Chris@1009 53 if (!file->exists()) {
Chris@1429 54 m_error = QFile::tr("File \"%1\" does not exist").arg(path);
Chris@1009 55 } else if (!file->open(QIODevice::ReadOnly | QIODevice::Text)) {
Chris@1429 56 m_error = QFile::tr("Failed to open file \"%1\"").arg(path);
Chris@148 57 } else {
Chris@1429 58 good = true;
Chris@148 59 }
Chris@148 60
Chris@1009 61 if (good) {
Chris@1009 62 m_device = file;
Chris@1030 63 m_filename = QFileInfo(path).fileName();
Chris@1009 64 } else {
Chris@1429 65 delete file;
Chris@148 66 }
Chris@148 67 }
Chris@148 68
Chris@1009 69 CSVFileReader::CSVFileReader(QIODevice *device, CSVFormat format,
Chris@1047 70 sv_samplerate_t mainModelSampleRate) :
Chris@1009 71 m_format(format),
Chris@1009 72 m_device(device),
Chris@1009 73 m_ownDevice(false),
Chris@1009 74 m_warnings(0),
Chris@1009 75 m_mainModelSampleRate(mainModelSampleRate)
Chris@1009 76 {
Chris@1009 77 }
Chris@1009 78
Chris@148 79 CSVFileReader::~CSVFileReader()
Chris@148 80 {
Chris@1009 81 SVDEBUG << "CSVFileReader::~CSVFileReader: device is " << m_device << endl;
Chris@148 82
Chris@1009 83 if (m_device && m_ownDevice) {
Chris@1009 84 SVDEBUG << "CSVFileReader::CSVFileReader: Closing device" << endl;
Chris@1009 85 m_device->close();
Chris@1009 86 delete m_device;
Chris@148 87 }
Chris@148 88 }
Chris@148 89
Chris@148 90 bool
Chris@148 91 CSVFileReader::isOK() const
Chris@148 92 {
Chris@1009 93 return (m_device != 0);
Chris@148 94 }
Chris@148 95
Chris@148 96 QString
Chris@148 97 CSVFileReader::getError() const
Chris@148 98 {
Chris@148 99 return m_error;
Chris@148 100 }
Chris@148 101
Chris@1038 102 sv_frame_t
Chris@1047 103 CSVFileReader::convertTimeValue(QString s, int lineno,
Chris@1047 104 sv_samplerate_t sampleRate,
Chris@929 105 int windowSize) const
Chris@631 106 {
Chris@631 107 QRegExp nonNumericRx("[^0-9eE.,+-]");
Chris@897 108 int warnLimit = 10;
Chris@631 109
Chris@631 110 CSVFormat::TimeUnits timeUnits = m_format.getTimeUnits();
Chris@631 111
Chris@1038 112 sv_frame_t calculatedFrame = 0;
Chris@631 113
Chris@631 114 bool ok = false;
Chris@631 115 QString numeric = s;
Chris@631 116 numeric.remove(nonNumericRx);
Chris@631 117
Chris@631 118 if (timeUnits == CSVFormat::TimeSeconds) {
Chris@631 119
Chris@631 120 double time = numeric.toDouble(&ok);
Chris@631 121 if (!ok) time = StringBits::stringToDoubleLocaleFree(numeric, &ok);
Chris@1038 122 calculatedFrame = sv_frame_t(time * sampleRate + 0.5);
Chris@990 123
Chris@990 124 } else if (timeUnits == CSVFormat::TimeMilliseconds) {
Chris@990 125
Chris@990 126 double time = numeric.toDouble(&ok);
Chris@990 127 if (!ok) time = StringBits::stringToDoubleLocaleFree(numeric, &ok);
Chris@1038 128 calculatedFrame = sv_frame_t((time / 1000.0) * sampleRate + 0.5);
Chris@631 129
Chris@631 130 } else {
Chris@631 131
Chris@631 132 long n = numeric.toLong(&ok);
Chris@631 133 if (n >= 0) calculatedFrame = n;
Chris@631 134
Chris@631 135 if (timeUnits == CSVFormat::TimeWindows) {
Chris@631 136 calculatedFrame *= windowSize;
Chris@631 137 }
Chris@631 138 }
Chris@631 139
Chris@631 140 if (!ok) {
Chris@631 141 if (m_warnings < warnLimit) {
Chris@1428 142 SVCERR << "WARNING: CSVFileReader::load: "
Chris@844 143 << "Bad time format (\"" << s
Chris@631 144 << "\") in data line "
Chris@843 145 << lineno+1 << endl;
Chris@631 146 } else if (m_warnings == warnLimit) {
Chris@1428 147 SVCERR << "WARNING: Too many warnings" << endl;
Chris@631 148 }
Chris@631 149 ++m_warnings;
Chris@631 150 }
Chris@631 151
Chris@631 152 return calculatedFrame;
Chris@631 153 }
Chris@631 154
Chris@148 155 Model *
Chris@148 156 CSVFileReader::load() const
Chris@148 157 {
Chris@1009 158 if (!m_device) return 0;
Chris@148 159
Chris@628 160 CSVFormat::ModelType modelType = m_format.getModelType();
Chris@392 161 CSVFormat::TimingType timingType = m_format.getTimingType();
Chris@628 162 CSVFormat::TimeUnits timeUnits = m_format.getTimeUnits();
Chris@1047 163 sv_samplerate_t sampleRate = m_format.getSampleRate();
Chris@929 164 int windowSize = m_format.getWindowSize();
Chris@631 165 QChar separator = m_format.getSeparator();
Chris@631 166 bool allowQuoting = m_format.getAllowQuoting();
Chris@148 167
Chris@392 168 if (timingType == CSVFormat::ExplicitTiming) {
Chris@611 169 if (modelType == CSVFormat::ThreeDimensionalModel) {
Chris@611 170 // This will be overridden later if more than one line
Chris@611 171 // appears in our file, but we want to choose a default
Chris@611 172 // that's likely to be visible
Chris@611 173 windowSize = 1024;
Chris@611 174 } else {
Chris@611 175 windowSize = 1;
Chris@611 176 }
Chris@1429 177 if (timeUnits == CSVFormat::TimeSeconds ||
Chris@990 178 timeUnits == CSVFormat::TimeMilliseconds) {
Chris@1429 179 sampleRate = m_mainModelSampleRate;
Chris@1429 180 }
Chris@148 181 }
Chris@148 182
Chris@148 183 SparseOneDimensionalModel *model1 = 0;
Chris@148 184 SparseTimeValueModel *model2 = 0;
Chris@628 185 RegionModel *model2a = 0;
Chris@897 186 NoteModel *model2b = 0;
Chris@152 187 EditableDenseThreeDimensionalModel *model3 = 0;
Chris@1488 188 WritableWaveFileModel *modelW = 0;
Chris@148 189 Model *model = 0;
Chris@148 190
Chris@1009 191 QTextStream in(m_device);
Chris@148 192
Chris@148 193 unsigned int warnings = 0, warnLimit = 10;
Chris@148 194 unsigned int lineno = 0;
Chris@148 195
Chris@148 196 float min = 0.0, max = 0.0;
Chris@148 197
Chris@1038 198 sv_frame_t frameNo = 0;
Chris@1038 199 sv_frame_t duration = 0;
Chris@1038 200 sv_frame_t endFrame = 0;
Chris@631 201
Chris@631 202 bool haveAnyValue = false;
Chris@631 203 bool haveEndTime = false;
Chris@897 204 bool pitchLooksLikeMIDI = true;
Chris@631 205
Chris@1038 206 sv_frame_t startFrame = 0; // for calculation of dense model resolution
Chris@631 207 bool firstEverValue = true;
Chris@148 208
Chris@1113 209 map<QString, int> labelCountMap;
Chris@631 210
Chris@676 211 int valueColumns = 0;
Chris@676 212 for (int i = 0; i < m_format.getColumnCount(); ++i) {
Chris@676 213 if (m_format.getColumnPurpose(i) == CSVFormat::ColumnValue) {
Chris@676 214 ++valueColumns;
Chris@676 215 }
Chris@676 216 }
Chris@676 217
Chris@148 218 while (!in.atEnd()) {
Chris@148 219
Chris@283 220 // QTextStream's readLine doesn't cope with old-style Mac
Chris@283 221 // CR-only line endings. Why did they bother making the class
Chris@283 222 // cope with more than one sort of line ending, if it still
Chris@283 223 // can't be configured to cope with all the common sorts?
Chris@148 224
Chris@283 225 // For the time being we'll deal with this case (which is
Chris@283 226 // relatively uncommon for us, but still necessary to handle)
Chris@283 227 // by reading the entire file using a single readLine, and
Chris@283 228 // splitting it. For CR and CR/LF line endings this will just
Chris@283 229 // read a line at a time, and that's obviously OK.
Chris@148 230
Chris@283 231 QString chunk = in.readLine();
Chris@283 232 QStringList lines = chunk.split('\r', QString::SkipEmptyParts);
Chris@283 233
Chris@897 234 for (int li = 0; li < lines.size(); ++li) {
Chris@148 235
Chris@283 236 QString line = lines[li];
Chris@1009 237
Chris@283 238 if (line.startsWith("#")) continue;
Chris@283 239
Chris@631 240 QStringList list = StringBits::split(line, separator, allowQuoting);
Chris@283 241 if (!model) {
Chris@283 242
Chris@283 243 switch (modelType) {
Chris@283 244
Chris@392 245 case CSVFormat::OneDimensionalModel:
Chris@283 246 model1 = new SparseOneDimensionalModel(sampleRate, windowSize);
Chris@283 247 model = model1;
Chris@283 248 break;
Chris@1429 249
Chris@392 250 case CSVFormat::TwoDimensionalModel:
Chris@283 251 model2 = new SparseTimeValueModel(sampleRate, windowSize, false);
Chris@283 252 model = model2;
Chris@283 253 break;
Chris@1429 254
Chris@628 255 case CSVFormat::TwoDimensionalModelWithDuration:
Chris@628 256 model2a = new RegionModel(sampleRate, windowSize, false);
Chris@628 257 model = model2a;
Chris@628 258 break;
Chris@1429 259
Chris@897 260 case CSVFormat::TwoDimensionalModelWithDurationAndPitch:
Chris@897 261 model2b = new NoteModel(sampleRate, windowSize, false);
Chris@897 262 model = model2b;
Chris@897 263 break;
Chris@1429 264
Chris@392 265 case CSVFormat::ThreeDimensionalModel:
Chris@535 266 model3 = new EditableDenseThreeDimensionalModel
Chris@535 267 (sampleRate,
Chris@535 268 windowSize,
Chris@676 269 valueColumns,
Chris@535 270 EditableDenseThreeDimensionalModel::NoCompression);
Chris@283 271 model = model3;
Chris@283 272 break;
Chris@1488 273
Chris@1488 274 case CSVFormat::WaveFileModel:
Chris@1488 275 modelW = new WritableWaveFileModel
Chris@1488 276 (sampleRate, valueColumns);
Chris@1488 277 model = modelW;
Chris@1488 278 break;
Chris@283 279 }
Chris@1030 280
Chris@1030 281 if (model) {
Chris@1030 282 if (m_filename != "") {
Chris@1030 283 model->setObjectName(m_filename);
Chris@1030 284 }
Chris@1030 285 }
Chris@283 286 }
Chris@148 287
Chris@631 288 float value = 0.f;
Chris@897 289 float pitch = 0.f;
Chris@631 290 QString label = "";
Chris@148 291
Chris@631 292 duration = 0.f;
Chris@631 293 haveEndTime = false;
Chris@628 294
Chris@283 295 for (int i = 0; i < list.size(); ++i) {
Chris@148 296
Chris@631 297 QString s = list[i];
Chris@631 298
Chris@631 299 CSVFormat::ColumnPurpose purpose = m_format.getColumnPurpose(i);
Chris@631 300
Chris@631 301 switch (purpose) {
Chris@631 302
Chris@631 303 case CSVFormat::ColumnUnknown:
Chris@631 304 break;
Chris@631 305
Chris@631 306 case CSVFormat::ColumnStartTime:
Chris@631 307 frameNo = convertTimeValue(s, lineno, sampleRate, windowSize);
Chris@631 308 break;
Chris@631 309
Chris@631 310 case CSVFormat::ColumnEndTime:
Chris@631 311 endFrame = convertTimeValue(s, lineno, sampleRate, windowSize);
Chris@631 312 haveEndTime = true;
Chris@631 313 break;
Chris@631 314
Chris@631 315 case CSVFormat::ColumnDuration:
Chris@631 316 duration = convertTimeValue(s, lineno, sampleRate, windowSize);
Chris@631 317 break;
Chris@631 318
Chris@631 319 case CSVFormat::ColumnValue:
Chris@631 320 value = s.toFloat();
Chris@631 321 haveAnyValue = true;
Chris@631 322 break;
Chris@631 323
Chris@897 324 case CSVFormat::ColumnPitch:
Chris@897 325 pitch = s.toFloat();
Chris@897 326 if (pitch < 0.f || pitch > 127.f) {
Chris@897 327 pitchLooksLikeMIDI = false;
Chris@897 328 }
Chris@897 329 break;
Chris@897 330
Chris@631 331 case CSVFormat::ColumnLabel:
Chris@631 332 label = s;
Chris@631 333 break;
Chris@283 334 }
Chris@631 335 }
Chris@148 336
Chris@1113 337 ++labelCountMap[label];
Chris@1113 338
Chris@631 339 if (haveEndTime) { // ... calculate duration now all cols read
Chris@631 340 if (endFrame > frameNo) {
Chris@631 341 duration = endFrame - frameNo;
Chris@628 342 }
Chris@283 343 }
Chris@148 344
Chris@392 345 if (modelType == CSVFormat::OneDimensionalModel) {
Chris@1429 346
Chris@631 347 SparseOneDimensionalModel::Point point(frameNo, label);
Chris@283 348 model1->addPoint(point);
Chris@148 349
Chris@392 350 } else if (modelType == CSVFormat::TwoDimensionalModel) {
Chris@148 351
Chris@631 352 SparseTimeValueModel::Point point(frameNo, value, label);
Chris@283 353 model2->addPoint(point);
Chris@148 354
Chris@628 355 } else if (modelType == CSVFormat::TwoDimensionalModelWithDuration) {
Chris@628 356
Chris@631 357 RegionModel::Point point(frameNo, value, duration, label);
Chris@628 358 model2a->addPoint(point);
Chris@628 359
Chris@897 360 } else if (modelType == CSVFormat::TwoDimensionalModelWithDurationAndPitch) {
Chris@897 361
Chris@897 362 float level = ((value >= 0.f && value <= 1.f) ? value : 1.f);
Chris@897 363 NoteModel::Point point(frameNo, pitch, duration, level, label);
Chris@897 364 model2b->addPoint(point);
Chris@897 365
Chris@392 366 } else if (modelType == CSVFormat::ThreeDimensionalModel) {
Chris@148 367
Chris@283 368 DenseThreeDimensionalModel::Column values;
Chris@148 369
Chris@631 370 for (int i = 0; i < list.size(); ++i) {
Chris@148 371
Chris@676 372 if (m_format.getColumnPurpose(i) != CSVFormat::ColumnValue) {
Chris@676 373 continue;
Chris@676 374 }
Chris@676 375
Chris@283 376 bool ok = false;
Chris@283 377 float value = list[i].toFloat(&ok);
Chris@611 378
Chris@676 379 values.push_back(value);
Chris@1429 380
Chris@631 381 if (firstEverValue || value < min) min = value;
Chris@631 382 if (firstEverValue || value > max) max = value;
Chris@676 383
Chris@631 384 if (firstEverValue) {
Chris@611 385 startFrame = frameNo;
Chris@611 386 model3->setStartFrame(startFrame);
Chris@611 387 } else if (lineno == 1 &&
Chris@611 388 timingType == CSVFormat::ExplicitTiming) {
Chris@1038 389 model3->setResolution(int(frameNo - startFrame));
Chris@611 390 }
Chris@631 391
Chris@631 392 firstEverValue = false;
Chris@148 393
Chris@283 394 if (!ok) {
Chris@283 395 if (warnings < warnLimit) {
Chris@1428 396 SVCERR << "WARNING: CSVFileReader::load: "
Chris@390 397 << "Non-numeric value \""
Chris@844 398 << list[i]
Chris@491 399 << "\" in data line " << lineno+1
Chris@843 400 << ":" << endl;
Chris@1428 401 SVCERR << line << endl;
Chris@283 402 ++warnings;
Chris@283 403 } else if (warnings == warnLimit) {
Chris@1428 404 // SVCERR << "WARNING: Too many warnings" << endl;
Chris@283 405 }
Chris@283 406 }
Chris@283 407 }
Chris@1429 408
Chris@690 409 // SVDEBUG << "Setting bin values for count " << lineno << ", frame "
Chris@687 410 // << frameNo << ", time " << RealTime::frame2RealTime(frameNo, sampleRate) << endl;
Chris@148 411
Chris@611 412 model3->setColumn(lineno, values);
Chris@1488 413
Chris@1488 414 } else if (modelType == CSVFormat::WaveFileModel) {
Chris@1488 415
Chris@1488 416 int channels = modelW->getChannelCount();
Chris@1488 417
Chris@1488 418 float **samples =
Chris@1488 419 breakfastquay::allocate_and_zero_channels<float>
Chris@1488 420 (channels, 1);
Chris@1488 421
Chris@1488 422 for (int i = 0; i < list.size() && i < channels; ++i) {
Chris@1488 423
Chris@1488 424 if (m_format.getColumnPurpose(i) != CSVFormat::ColumnValue) {
Chris@1488 425 continue;
Chris@1488 426 }
Chris@1488 427
Chris@1488 428 bool ok = false;
Chris@1488 429 float value = list[i].toFloat(&ok);
Chris@1488 430
Chris@1488 431 samples[i][0] = value;
Chris@1488 432 }
Chris@1488 433
Chris@1488 434 bool ok = modelW->addSamples(samples, 1);
Chris@1488 435
Chris@1488 436 breakfastquay::deallocate_channels(samples, channels);
Chris@1488 437
Chris@1488 438 if (!ok) {
Chris@1488 439 if (warnings < warnLimit) {
Chris@1488 440 SVCERR << "WARNING: CSVFileReader::load: "
Chris@1488 441 << "Unable to add sample to wave-file model"
Chris@1488 442 << endl;
Chris@1488 443 SVCERR << line << endl;
Chris@1488 444 ++warnings;
Chris@1488 445 }
Chris@1488 446 }
Chris@283 447 }
Chris@1488 448
Chris@283 449 ++lineno;
Chris@392 450 if (timingType == CSVFormat::ImplicitTiming ||
Chris@283 451 list.size() == 0) {
Chris@283 452 frameNo += windowSize;
Chris@283 453 }
Chris@283 454 }
Chris@148 455 }
Chris@148 456
Chris@631 457 if (!haveAnyValue) {
Chris@631 458 if (model2a) {
Chris@631 459 // assign values for regions based on label frequency; we
Chris@631 460 // have this in our labelCountMap, sort of
Chris@631 461
Chris@1113 462 map<int, map<QString, float> > countLabelValueMap;
Chris@1113 463 for (map<QString, int>::iterator i = labelCountMap.begin();
Chris@631 464 i != labelCountMap.end(); ++i) {
Chris@1113 465 countLabelValueMap[i->second][i->first] = -1.f;
Chris@631 466 }
Chris@631 467
Chris@631 468 float v = 0.f;
Chris@1113 469 for (map<int, map<QString, float> >::iterator i =
Chris@631 470 countLabelValueMap.end(); i != countLabelValueMap.begin(); ) {
Chris@631 471 --i;
Chris@1428 472 SVCERR << "count -> " << i->first << endl;
Chris@1113 473 for (map<QString, float>::iterator j = i->second.begin();
Chris@631 474 j != i->second.end(); ++j) {
Chris@631 475 j->second = v;
Chris@1428 476 SVCERR << "label -> " << j->first << ", value " << v << endl;
Chris@631 477 v = v + 1.f;
Chris@631 478 }
Chris@631 479 }
Chris@631 480
Chris@1113 481 map<RegionModel::Point, RegionModel::Point,
Chris@631 482 RegionModel::Point::Comparator> pointMap;
Chris@631 483 for (RegionModel::PointList::const_iterator i =
Chris@631 484 model2a->getPoints().begin();
Chris@631 485 i != model2a->getPoints().end(); ++i) {
Chris@631 486 RegionModel::Point p(*i);
Chris@1113 487 int count = labelCountMap[p.label];
Chris@1113 488 v = countLabelValueMap[count][p.label];
Chris@1428 489 // SVCERR << "mapping from label \"" << p.label << "\" (count " << count << ") to value " << v << endl;
Chris@631 490 RegionModel::Point pp(p.frame, v, p.duration, p.label);
Chris@631 491 pointMap[p] = pp;
Chris@631 492 }
Chris@631 493
Chris@1113 494 for (map<RegionModel::Point, RegionModel::Point>::iterator i =
Chris@631 495 pointMap.begin(); i != pointMap.end(); ++i) {
Chris@1113 496 // There could be duplicate regions; if so replace
Chris@1113 497 // them all -- but we need to check we're not
Chris@1113 498 // replacing a region by itself (or else this will
Chris@1113 499 // never terminate)
Chris@1113 500 if (i->first.value == i->second.value) {
Chris@1113 501 continue;
Chris@1113 502 }
Chris@1113 503 while (model2a->containsPoint(i->first)) {
Chris@1113 504 model2a->deletePoint(i->first);
Chris@1113 505 model2a->addPoint(i->second);
Chris@1113 506 }
Chris@631 507 }
Chris@631 508 }
Chris@631 509 }
Chris@631 510
Chris@897 511 if (model2b) {
Chris@897 512 if (pitchLooksLikeMIDI) {
Chris@897 513 model2b->setScaleUnits("MIDI Pitch");
Chris@897 514 } else {
Chris@897 515 model2b->setScaleUnits("Hz");
Chris@897 516 }
Chris@897 517 }
Chris@897 518
Chris@961 519 if (model3) {
Chris@1429 520 model3->setMinimumLevel(min);
Chris@1429 521 model3->setMaximumLevel(max);
Chris@148 522 }
Chris@148 523
Chris@148 524 return model;
Chris@148 525 }
Chris@148 526