1 package org.opentrafficsim.road.od;
2
3 import java.util.ArrayList;
4 import java.util.Collections;
5 import java.util.Comparator;
6 import java.util.LinkedHashMap;
7 import java.util.LinkedHashSet;
8 import java.util.List;
9 import java.util.Map;
10 import java.util.NoSuchElementException;
11 import java.util.Optional;
12 import java.util.Set;
13 import java.util.TreeMap;
14
15 import org.djunits.unit.DurationUnit;
16 import org.djunits.unit.FrequencyUnit;
17 import org.djunits.value.ValueRuntimeException;
18 import org.djunits.value.vdouble.scalar.Duration;
19 import org.djunits.value.vdouble.scalar.Frequency;
20 import org.djunits.value.vdouble.scalar.Time;
21 import org.djunits.value.vdouble.vector.DurationVector;
22 import org.djunits.value.vdouble.vector.FrequencyVector;
23 import org.djutils.base.Identifiable;
24 import org.djutils.exceptions.Throw;
25 import org.opentrafficsim.base.OtsRuntimeException;
26 import org.opentrafficsim.core.network.NetworkException;
27 import org.opentrafficsim.core.network.Node;
28 import org.opentrafficsim.core.network.route.Route;
29 import org.opentrafficsim.road.gtu.generator.headway.DemandPattern;
30
31
32
33
34
35
36
37
38
39
40
41
42
43 public class OdMatrix implements Identifiable
44 {
45
46
47 private final String id;
48
49
50 private final List<Node> origins;
51
52
53 private final List<Node> destinations;
54
55
56 private final Categorization categorization;
57
58
59 private final DurationVector globalTimeVector;
60
61
62 private final Interpolation globalInterpolation;
63
64
65 private final Map<Node, Map<Node, Map<Category, DemandPattern>>> demandData = new LinkedHashMap<>();
66
67
68 private static final Comparator<Node> COMPARATOR = new Comparator<Node>()
69 {
70 @Override
71 public int compare(final Node o1, final Node o2)
72 {
73 return o1.getId().compareTo(o2.getId());
74 }
75 };
76
77
78
79
80
81
82
83
84
85
86
87 public OdMatrix(final String id, final List<? extends Node> origins, final List<? extends Node> destinations,
88 final Categorization categorization, final DurationVector globalDurationVector,
89 final Interpolation globalInterpolation)
90 {
91 Throw.whenNull(id, "Id may not be null.");
92 Throw.whenNull(origins, "Origins may not be null.");
93 Throw.when(origins.contains(null), NullPointerException.class, "Origin may not contain null.");
94 Throw.whenNull(destinations, "Destination may not be null.");
95 Throw.when(destinations.contains(null), NullPointerException.class, "Destination may not contain null.");
96 Throw.whenNull(categorization, "Categorization may not be null.");
97
98
99 this.id = id;
100 this.origins = new ArrayList<>(origins);
101 this.destinations = new ArrayList<>(destinations);
102 Collections.sort(this.origins, COMPARATOR);
103 Collections.sort(this.destinations, COMPARATOR);
104 this.categorization = categorization;
105 this.globalTimeVector = globalDurationVector;
106 this.globalInterpolation = globalInterpolation;
107
108 for (Node origin : origins)
109 {
110 Map<Node, Map<Category, DemandPattern>> map = new LinkedHashMap<>();
111 for (Node destination : destinations)
112 {
113 map.put(destination, new TreeMap<>(new Comparator<Category>()
114 {
115 @Override
116 public int compare(final Category o1, final Category o2)
117 {
118 for (int i = 0; i < o1.getCategorization().size(); i++)
119 {
120 int order = Integer.compare(o1.get(i).hashCode(), o2.get(i).hashCode());
121 if (order != 0)
122 {
123 return order;
124 }
125 }
126 return 0;
127 }
128 }));
129 }
130 this.demandData.put(origin, map);
131 }
132 }
133
134 @Override
135 public final String getId()
136 {
137 return this.id;
138 }
139
140
141
142
143
144 public final List<Node> getOrigins()
145 {
146 return new ArrayList<>(this.origins);
147 }
148
149
150
151
152
153 public final List<Node> getDestinations()
154 {
155 return new ArrayList<>(this.destinations);
156 }
157
158
159
160
161
162 public final Categorization getCategorization()
163 {
164 return this.categorization;
165 }
166
167
168
169
170
171 public final DurationVector getGlobalTimeVector()
172 {
173 return this.globalTimeVector;
174 }
175
176
177
178
179
180 public final Interpolation getGlobalInterpolation()
181 {
182 return this.globalInterpolation;
183 }
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199 public final void putDemandVector(final Node origin, final Node destination, final Category category,
200 final FrequencyVector demand, final double fraction)
201 {
202 putDemandVector(origin, destination, category, demand, this.globalTimeVector, this.globalInterpolation, fraction);
203 }
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218 public final void putDemandVector(final Node origin, final Node destination, final Category category,
219 final FrequencyVector demand)
220 {
221 putDemandVector(origin, destination, category, demand, this.globalTimeVector, this.globalInterpolation);
222 }
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241 public final void putDemandVector(final Node origin, final Node destination, final Category category,
242 final FrequencyVector demand, final DurationVector timeVector, final Interpolation interpolation)
243 {
244 Throw.whenNull(origin, "Origin may not be null.");
245 Throw.whenNull(destination, "Destination may not be null.");
246 Throw.whenNull(category, "Category may not be null.");
247 Throw.whenNull(demand, "Demand data may not be null.");
248 Throw.whenNull(timeVector, "Time vector may not be null.");
249 Throw.whenNull(interpolation, "Interpolation may not be null.");
250 Throw.when(!this.origins.contains(origin), IllegalArgumentException.class, "Origin '%s' is not part of the OD matrix.",
251 origin);
252 Throw.when(!this.destinations.contains(destination), IllegalArgumentException.class,
253 "Destination '%s' is not part of the OD matrix.", destination);
254 Throw.when(!this.categorization.equals(category.getCategorization()), IllegalArgumentException.class,
255 "Provided category %s does not belong to the categorization %s.", category, this.categorization);
256 Throw.when(demand.size() != timeVector.size() || demand.size() < 2, IllegalArgumentException.class,
257 "Demand data has different length than time vector, or has less than 2 values.");
258 for (Frequency q : demand)
259 {
260 Throw.when(q.lt0(), IllegalArgumentException.class, "Demand contains negative value(s).");
261 }
262 Duration prevTime;
263 try
264 {
265 prevTime = timeVector.get(0).eq0() ? Duration.ofSI(-1.0) : Duration.ZERO;
266 }
267 catch (ValueRuntimeException exception)
268 {
269
270 throw new OtsRuntimeException("Unexpected exception while checking time vector.", exception);
271 }
272 for (Duration time : timeVector)
273 {
274 Throw.when(prevTime.ge(time), IllegalArgumentException.class,
275 "Time vector is not strictly increasing, or contains negative time.");
276 prevTime = time;
277 }
278 if (this.categorization.entails(Route.class))
279 {
280 Route route = category.get(Route.class);
281 try
282 {
283 Throw.when(!route.originNode().equals(origin) || !route.destinationNode().equals(destination),
284 IllegalArgumentException.class,
285 "Route from %s to %s does not comply with origin %s and destination %s.", route.originNode(),
286 route.destinationNode(), origin, destination);
287 }
288 catch (NetworkException exception)
289 {
290 throw new IllegalArgumentException("Route in OD has no nodes.", exception);
291 }
292 }
293 DurationVector durationVector = new DurationVector(timeVector.getValuesInUnit(), timeVector.getDisplayUnit());
294 DemandPattern demandPattern = new DemandPattern(demand, durationVector, interpolation);
295 this.demandData.get(origin).get(destination).put(category, demandPattern);
296 }
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314 public final void putDemandVector(final Node origin, final Node destination, final Category category,
315 final FrequencyVector demand, final DurationVector timeVector, final Interpolation interpolation,
316 final double fraction)
317 {
318 Throw.whenNull(demand, "Demand data may not be null.");
319 FrequencyVector demandScaled;
320 if (fraction == 1.0)
321 {
322 demandScaled = demand;
323 }
324 else
325 {
326 double[] in = demand.getValuesInUnit();
327 double[] scaled = new double[in.length];
328 for (int i = 0; i < in.length; i++)
329 {
330 scaled[i] = in[i] * fraction;
331 }
332 try
333 {
334 demandScaled = new FrequencyVector(scaled, demand.getDisplayUnit(), demand.getStorageType());
335 }
336 catch (ValueRuntimeException exception)
337 {
338
339 throw new OtsRuntimeException("An object was null.", exception);
340 }
341 }
342 putDemandVector(origin, destination, category, demandScaled, timeVector, interpolation);
343 }
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361 public final void putDemandVector(final Node origin, final Node destination, final Category category,
362 final FrequencyVector demand, final DurationVector timeVector, final Interpolation interpolation,
363 final double[] fraction)
364 {
365 Throw.whenNull(demand, "Demand data may not be null.");
366 Throw.whenNull(fraction, "Fraction data may not be null.");
367 Throw.whenNull(timeVector, "Time vector may not be null.");
368 Throw.when(demand.size() != timeVector.size() || timeVector.size() != fraction.length, IllegalArgumentException.class,
369 "Arrays are of unequal length: demand=%d, DurationVector=%d, fraction=%d", demand.size(), timeVector.size(),
370 fraction.length);
371 double[] in = demand.getValuesInUnit();
372 double[] scaled = new double[in.length];
373 for (int i = 0; i < in.length; i++)
374 {
375 scaled[i] = in[i] * fraction[i];
376 }
377 FrequencyVector demandScaled;
378 try
379 {
380 demandScaled = new FrequencyVector(scaled, demand.getDisplayUnit(), demand.getStorageType());
381 }
382 catch (ValueRuntimeException exception)
383 {
384
385 throw new OtsRuntimeException("An object was null.", exception);
386 }
387 putDemandVector(origin, destination, category, demandScaled, timeVector, interpolation);
388 }
389
390
391
392
393
394
395
396
397
398
399
400 public final Optional<FrequencyVector> getDemandVector(final Node origin, final Node destination, final Category category)
401 {
402 Optional<DemandPattern> demandPattern = getDemandPattern(origin, destination, category);
403 if (demandPattern.isEmpty())
404 {
405 return Optional.empty();
406 }
407 return Optional.of(demandPattern.get().demandVector());
408 }
409
410
411
412
413
414
415
416
417
418
419
420 public final Optional<DurationVector> getDurationVector(final Node origin, final Node destination, final Category category)
421 {
422 Optional<DemandPattern> demandPattern = getDemandPattern(origin, destination, category);
423 if (demandPattern.isEmpty())
424 {
425 return Optional.empty();
426 }
427 return Optional.of(demandPattern.get().timeVector());
428 }
429
430
431
432
433
434
435
436
437
438
439
440 public final Optional<Interpolation> getInterpolation(final Node origin, final Node destination, final Category category)
441 {
442 Optional<DemandPattern> demandPattern = getDemandPattern(origin, destination, category);
443 if (demandPattern.isEmpty())
444 {
445 return Optional.empty();
446 }
447 return Optional.of(demandPattern.get().interpolation());
448 }
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463 public final Frequency getDemand(final Node origin, final Node destination, final Category category, final Time time,
464 final boolean sliceStart)
465 {
466 Throw.whenNull(time, "Time may not be null.");
467 Optional<DemandPattern> demandPattern = getDemandPattern(origin, destination, category);
468 if (demandPattern.isEmpty())
469 {
470 return new Frequency(0.0, FrequencyUnit.PER_HOUR);
471 }
472 return demandPattern.get().getFrequency(Duration.ofSI(time.si), sliceStart);
473 }
474
475
476
477
478
479
480
481
482
483
484
485 public Optional<DemandPattern> getDemandPattern(final Node origin, final Node destination, final Category category)
486 {
487 Throw.whenNull(origin, "Origin may not be null.");
488 Throw.whenNull(destination, "Destination may not be null.");
489 Throw.whenNull(category, "Category may not be null.");
490 Throw.when(!this.origins.contains(origin), IllegalArgumentException.class, "Origin '%s' is not part of the OD matrix",
491 origin);
492 Throw.when(!this.destinations.contains(destination), IllegalArgumentException.class,
493 "Destination '%s' is not part of the OD matrix.", destination);
494 Throw.when(!this.categorization.equals(category.getCategorization()), IllegalArgumentException.class,
495 "Provided category %s does not belong to the categorization %s.", category, this.categorization);
496 return Optional.ofNullable(this.demandData.get(origin).get(destination).get(category));
497 }
498
499
500
501
502
503
504
505
506
507
508
509 public final boolean contains(final Node origin, final Node destination, final Category category)
510 {
511 return getDemandPattern(origin, destination, category) != null;
512 }
513
514
515
516
517
518
519
520
521
522 public final Set<Category> getCategories(final Node origin, final Node destination)
523 {
524 Throw.whenNull(origin, "Origin may not be null.");
525 Throw.whenNull(destination, "Destination may not be null.");
526 Throw.when(!this.origins.contains(origin), IllegalArgumentException.class, "Origin '%s' is not part of the OD matrix",
527 origin);
528 Throw.when(!this.destinations.contains(destination), IllegalArgumentException.class,
529 "Destination '%s' is not part of the OD matrix.", destination);
530 return new LinkedHashSet<>(this.demandData.get(origin).get(destination).keySet());
531 }
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551 public final void putTripsVector(final Node origin, final Node destination, final Category category, final int[] trips)
552 {
553 putTripsVector(origin, destination, category, trips, getGlobalTimeVector());
554 }
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570 public final void putTripsVector(final Node origin, final Node destination, final Category category, final int[] trips,
571 final DurationVector timeVector)
572 {
573
574 Throw.whenNull(trips, "Demand data may not be null.");
575 Throw.whenNull(timeVector, "Time vector may not be null.");
576 Throw.when(trips.length != timeVector.size() - 1, IllegalArgumentException.class,
577 "Trip data and time data have wrong lengths. Trip data should be 1 shorter than time data.");
578
579 double[] flow = new double[timeVector.size()];
580 try
581 {
582 for (int i = 0; i < trips.length; i++)
583 {
584 flow[i] = trips[i]
585 / (timeVector.get(i + 1).getInUnit(DurationUnit.HOUR) - timeVector.get(i).getInUnit(DurationUnit.HOUR));
586 }
587
588 putDemandVector(origin, destination, category, new FrequencyVector(flow, FrequencyUnit.PER_HOUR), timeVector,
589 Interpolation.STEPWISE);
590 }
591 catch (ValueRuntimeException exception)
592 {
593
594 throw new OtsRuntimeException("Could not translate trip vector into demand vector.", exception);
595 }
596 }
597
598
599
600
601
602
603
604
605
606
607
608 public final int[] getTripsVector(final Node origin, final Node destination, final Category category)
609 {
610 Optional<FrequencyVector> demand = getDemandVector(origin, destination, category);
611 if (demand.isEmpty())
612 {
613 return null;
614 }
615 int[] trips = new int[demand.get().size() - 1];
616 DurationVector time = getDurationVector(origin, destination, category).get();
617 Interpolation interpolation = getInterpolation(origin, destination, category).get();
618 for (int i = 0; i < trips.length; i++)
619 {
620 try
621 {
622 trips[i] = interpolation.integrate(demand.get().get(i), time.get(i), demand.get().get(i + 1), time.get(i + 1));
623 }
624 catch (ValueRuntimeException exception)
625 {
626
627 throw new OtsRuntimeException("Could not translate demand vector into trip vector.", exception);
628 }
629 }
630 return trips;
631 }
632
633
634
635
636
637
638
639
640
641
642
643
644
645 public final int getTrips(final Node origin, final Node destination, final Category category, final int periodIndex)
646 {
647 Optional<DurationVector> time = getDurationVector(origin, destination, category);
648 if (time.isEmpty())
649 {
650 return 0;
651 }
652 Throw.when(periodIndex < 0 || periodIndex >= time.get().size() - 1, IndexOutOfBoundsException.class,
653 "Period index out of range.");
654 FrequencyVector demand = getDemandVector(origin, destination, category).get();
655 Interpolation interpolation = getInterpolation(origin, destination, category).get();
656 try
657 {
658 return interpolation.integrate(demand.get(periodIndex), time.get().get(periodIndex), demand.get(periodIndex + 1),
659 time.get().get(periodIndex + 1));
660 }
661 catch (ValueRuntimeException exception)
662 {
663
664 throw new OtsRuntimeException("Could not get number of trips.", exception);
665 }
666 }
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683 public final void increaseTrips(final Node origin, final Node destination, final Category category, final int periodIndex,
684 final int trips)
685 {
686 Interpolation interpolation = getInterpolation(origin, destination, category)
687 .orElseThrow(() -> new NoSuchElementException("No data to increase for OD " + origin.getId() + ", "
688 + destination.getId() + ", category " + category));
689 Throw.when(!interpolation.equals(Interpolation.STEPWISE), UnsupportedOperationException.class,
690 "Can only increase the number of trips for data with stepwise interpolation.");
691 DurationVector time = getDurationVector(origin, destination, category).get();
692 Throw.when(periodIndex < 0 || periodIndex >= time.size() - 1, IndexOutOfBoundsException.class,
693 "Period index out of range.");
694 FrequencyVector demand = getDemandVector(origin, destination, category).get();
695 try
696 {
697 double additionalDemand = trips / (time.get(periodIndex + 1).getInUnit(DurationUnit.HOUR)
698 - time.get(periodIndex).getInUnit(DurationUnit.HOUR));
699 double[] dem = demand.getValuesInUnit(FrequencyUnit.PER_HOUR);
700 Throw.when(dem[periodIndex] < -additionalDemand, UnsupportedOperationException.class,
701 "Demand may not become negative.");
702 dem[periodIndex] += additionalDemand;
703 DurationVector timeVector = new DurationVector(time.getValuesInUnit());
704 putDemandVector(origin, destination, category, new FrequencyVector(dem, FrequencyUnit.PER_HOUR), timeVector,
705 Interpolation.STEPWISE);
706 }
707 catch (ValueRuntimeException exception)
708 {
709
710 throw new OtsRuntimeException("Unexpected exception while getting number of trips.", exception);
711 }
712 }
713
714
715
716
717
718
719
720
721 public final int originTotal(final Node origin)
722 {
723 int sum = 0;
724 for (Node destination : getDestinations())
725 {
726 sum += originDestinationTotal(origin, destination);
727 }
728 return sum;
729 }
730
731
732
733
734
735
736
737
738 public final int destinationTotal(final Node destination)
739 {
740 int sum = 0;
741 for (Node origin : getOrigins())
742 {
743 sum += originDestinationTotal(origin, destination);
744 }
745 return sum;
746 }
747
748
749
750
751
752
753
754 public final int matrixTotal()
755 {
756 int sum = 0;
757 for (Node origin : getOrigins())
758 {
759 for (Node destination : getDestinations())
760 {
761 sum += originDestinationTotal(origin, destination);
762 }
763 }
764 return sum;
765 }
766
767
768
769
770
771
772
773
774
775 public final int originDestinationTotal(final Node origin, final Node destination)
776 {
777 int sum = 0;
778 for (Category category : getCategories(origin, destination))
779 {
780 Optional<Interpolation> interpolation = getInterpolation(origin, destination, category);
781 if (interpolation.isPresent())
782 {
783 DurationVector time = getDurationVector(origin, destination, category).get();
784 FrequencyVector demand = getDemandVector(origin, destination, category).get();
785 for (int i = 0; i < time.size() - 1; i++)
786 {
787 try
788 {
789 sum += interpolation.get().integrate(demand.get(i), time.get(i), demand.get(i + 1), time.get(i + 1));
790 }
791 catch (ValueRuntimeException exception)
792 {
793
794 throw new OtsRuntimeException("Unexcepted exception while determining total trips over time.",
795 exception);
796 }
797 }
798 }
799 }
800 return sum;
801 }
802
803
804
805
806
807 @Override
808 @SuppressWarnings("checkstyle:designforextension")
809 public String toString()
810 {
811 return "OdMatrix [" + this.id + ", " + this.origins.size() + " origins, " + this.destinations.size() + " destinations, "
812 + this.categorization + " ]";
813 }
814
815
816
817
818 public final void print()
819 {
820 int originLength = 0;
821 for (Node origin : this.origins)
822 {
823 originLength = originLength >= origin.getId().length() ? originLength : origin.getId().length();
824 }
825 int destinLength = 0;
826 for (Node destination : this.destinations)
827 {
828 destinLength = destinLength >= destination.getId().length() ? destinLength : destination.getId().length();
829 }
830 String format = "%-" + Math.max(originLength, 1) + "s -> %-" + Math.max(destinLength, 1) + "s | ";
831 for (Node origin : this.origins)
832 {
833 Map<Node, Map<Category, DemandPattern>> destinationMap = this.demandData.get(origin);
834 for (Node destination : this.destinations)
835 {
836 Map<Category, DemandPattern> categoryMap = destinationMap.get(destination);
837 if (categoryMap.isEmpty())
838 {
839 System.out.println(String.format(format, origin.getId(), destination.getId()) + "-no data-");
840 }
841 else
842 {
843 for (Category category : categoryMap.keySet())
844 {
845 StringBuilder catStr = new StringBuilder("[");
846 String sep = "";
847 for (int i = 0; i < category.getCategorization().size(); i++)
848 {
849 catStr.append(sep);
850 Object obj = category.get(i);
851 if (obj instanceof Route)
852 {
853 catStr.append("Route: " + ((Route) obj).getId());
854 }
855 else
856 {
857 catStr.append(obj);
858 }
859 sep = ", ";
860 }
861 catStr.append("]");
862 System.out.println(String.format(format, origin.getId(), destination.getId()) + catStr + " | "
863 + categoryMap.get(category).demandVector());
864 }
865 }
866 }
867 }
868 }
869
870 @Override
871 public final int hashCode()
872 {
873 final int prime = 31;
874 int result = 1;
875 result = prime * result + ((this.categorization == null) ? 0 : this.categorization.hashCode());
876 result = prime * result + ((this.demandData == null) ? 0 : this.demandData.hashCode());
877 result = prime * result + ((this.destinations == null) ? 0 : this.destinations.hashCode());
878 result = prime * result + ((this.globalInterpolation == null) ? 0 : this.globalInterpolation.hashCode());
879 result = prime * result + ((this.globalTimeVector == null) ? 0 : this.globalTimeVector.hashCode());
880 result = prime * result + ((this.id == null) ? 0 : this.id.hashCode());
881 result = prime * result + ((this.origins == null) ? 0 : this.origins.hashCode());
882 return result;
883 }
884
885 @Override
886 public final boolean equals(final Object obj)
887 {
888 if (this == obj)
889 {
890 return true;
891 }
892 if (obj == null)
893 {
894 return false;
895 }
896 if (getClass() != obj.getClass())
897 {
898 return false;
899 }
900 OdMatrix other = (OdMatrix) obj;
901 if (this.categorization == null)
902 {
903 if (other.categorization != null)
904 {
905 return false;
906 }
907 }
908 else if (!this.categorization.equals(other.categorization))
909 {
910 return false;
911 }
912 if (this.demandData == null)
913 {
914 if (other.demandData != null)
915 {
916 return false;
917 }
918 }
919 else if (!this.demandData.equals(other.demandData))
920 {
921 return false;
922 }
923 if (this.destinations == null)
924 {
925 if (other.destinations != null)
926 {
927 return false;
928 }
929 }
930 else if (!this.destinations.equals(other.destinations))
931 {
932 return false;
933 }
934 if (this.globalInterpolation != other.globalInterpolation)
935 {
936 return false;
937 }
938 if (this.globalTimeVector == null)
939 {
940 if (other.globalTimeVector != null)
941 {
942 return false;
943 }
944 }
945 else if (!this.globalTimeVector.equals(other.globalTimeVector))
946 {
947 return false;
948 }
949 if (this.id == null)
950 {
951 if (other.id != null)
952 {
953 return false;
954 }
955 }
956 else if (!this.id.equals(other.id))
957 {
958 return false;
959 }
960 if (this.origins == null)
961 {
962 if (other.origins != null)
963 {
964 return false;
965 }
966 }
967 else if (!this.origins.equals(other.origins))
968 {
969 return false;
970 }
971 return true;
972 }
973
974 }