blob: 8f5a01ad7f2b9cfb26900f9ad361d71fbdbd24e3 [file] [log] [blame]
Brian Silverman8fa2aad2017-06-10 16:45:30 -07001#!/usr/bin/env ruby
2
3# This makes DNS lookups actually work reliably and give useful errors.
4require 'resolv-replace.rb'
5
6require 'yaml'
7require 'optparse'
8
9require './parts'
10require './gschem_file'
11
12=begin
13Property names in the schematic file:
14 value is the main value of the component.
15 secondary_value is another value of the component.
16 The value in the schematic is only a minimum.
17 power rating for resistors.
18 voltage for capacitors.
19 pn_optional set to 1 means that the pn property is non-critical.
20 tolerance is a maximum tolerance for value.
21=end
22
23options = {}
24OptionParser.new do |opts|
25 opts.banner = "Usage: ordering.rb file.sch [options]"
26
27 opts.on('--myro-bom', String, :REQUIRED, 'Generate myropcb BOM') do |v|
28 options[:bom_file] = v
29 options[:bom_style] = :myro
30 end
31 opts.on('--mouser-bom', String, :REQUIRED, 'Generate Mouser BOM (ONLY MOUSER PARTS)') do |v|
32 options[:bom_file] = v
33 options[:bom_style] = :mouser
34 end
35 opts.on('--bom', String, :REQUIRED, 'Generate a nice BOM') do |v|
36 options[:bom_file] = v
37 options[:bom_style] = :nice
38 end
39end.parse!
40
41options[:order_quantity] = 2
42options[:include_dev] = true
43
44if options[:bom_file]
45 $bom_file = File.open(options[:bom_file], 'w')
46 case options[:bom_style]
47 when :myro
48 $bom_file.puts 'Qty,Part Reference,Part Number,MFGR,Distributor NO,DESCRIPTION,FOOTPRINT,DNI'
49 when :mouser
50 $bom_file.puts 'Mfg Part Number,Quantity,Description'
51 else
52 $bom_file.puts 'Quantity,Refdes,Part Number,Footprint,Value,Unit Price'
53 end
54end
55
56if ARGV.size == 0
57 raise 'Need schematics to look at!'
58end
59puts ARGV
60filenames = get_schematic_filenames ARGV
61
62$parts_yaml = YAML.load_file('parts.yaml')
63
64module Unicode
65 NBSP = "\u00A0"
66 PlusMinus = "\u00B1"
67 Micro = "\u00B5"
68end
69
70def chomp_zeros s
71 while s[-1] == '0'
72 s.chomp! '0'
73 end
74end
75
76class Part
77 SecondaryValueRegex = /^([0-9.]+) (.*)$/
78
79 attr_reader :refdeses
80 attr_reader :pn, :value, :footprint, :device, :secondary_value, :tolerance
81 attr_reader :pn_optional, :dev_only
82
83 attr_reader :low_quantity
84
85 def quantity
86 @refdeses.size
87 end
88
89 def initialize(pn, value, footprint, device, secondary_value, refdes, tolerance, pn_optional, dev_only)
90 @pn, @value, @footprint, @device = pn, value, footprint, device
91 @secondary_value, @tolerance = secondary_value, tolerance
92 @pn_optional, @dev_only = pn_optional, dev_only
93
94 puts "#{refdes} has no footprint" unless footprint
95
96 @refdeses = [refdes]
97 end
98 def found_another(pn, value, footprint, device, secondary_value, refdes, tolerance, pn_optional, dev_only)
99 error = false
100 if pn != @pn
101 puts "Error: pn #@pn vs #{pn}."
102 error = true
103 end
104 if value != @value
105 puts "Error: value #@value vs #{value}."
106 error = true
107 end
108 if footprint != @footprint
109 puts "Error: footprint #@footprint vs #{footprint}."
110 error = true
111 end
112 if device != @device
113 puts "Error: device #@device vs #{device}."
114 error = true
115 end
116
117 if pn_optional != @pn_optional
118 puts "Error: pn_optional #@pn_optional vs #{pn_optional}."
119 error = true
120 end
121 if dev_only != @dev_only
122 puts "Error: dev_only #@dev_only vs #{dev_only}."
123 error = true
124 end
125
126 if tolerance && @tolerance
127 if tolerance != @tolerance
128 puts "Error: tolerance #@tolerance vs #{tolerance}."
129 error = true
130 end
131 elsif tolerance
132 @tolerance = tolerance
133 end
134
135 new_secondary_match = SecondaryValueRegex.match secondary_value
136 my_secondary_match = SecondaryValueRegex.match @secondary_value
137 if new_secondary_match && my_secondary_match &&
138 new_secondary_match[2] == my_secondary_match[2]
139 new_value = new_secondary_match[1].to_f
140 my_value = my_secondary_match[1].to_f
141 @secondary_value = [new_value, my_value].max.to_s + ' ' + my_secondary_match[2]
142 else
143 if secondary_value != @secondary_value
144 puts "Error: secondary_value #@secondary_value vs #{secondary_value}."
145 error = true
146 end
147 end
148
149 if error
150 puts "\tAdding #{refdes} to #{@refdeses.inspect}"
151 end
152
153 @refdeses.push refdes unless @refdeses.include? refdes
154 end
155
156 def override_pn pn
157 @pn = pn
158 end
159 def override_footprint footprint
160 @footprint = footprint
161 end
162end
163
164$parts = {}
165
166def add_part(attrs, refdes)
167 args = [attrs[:pn], attrs[:value], attrs[:footprint], attrs[:device],
168 attrs[:secondary_value], refdes, attrs[:tolerance],
169 attrs[:pn_optional], attrs[:dev_only]]
170 key = attrs[:pn]
171 unless key
172 key = attrs[:value] || ''
173 key += attrs[:footprint] || ''
174 end
175 raise "No pn or value for #{args} (#{refdes})" unless key && !key.empty?
176 if $parts.include?(key)
177 $parts[key].found_another(*args)
178 else
179 $parts[key] = Part.new(*args)
180 end
181end
182
183def set_or_same(old, new)
184 if new
185 if old
186 if old != new
187 raise "#{old.inspect} != #{new.inspect}"
188 end
189 end
190 new
191 else
192 old
193 end
194end
195
196MouserKeyRegex = /^\n(.*):\n$/
197MouserValueRegex = /^\n(.*)\n$/
198
199def mouser_table_to_map table
200 raise "Bad table from Mouser" if table.empty?
201 r = {}
202 rows = table.css('tr > td > table > tr')
203 rows.each do |row|
204 raise "Unexpected HTML" unless row.elements.length == 3
205
206 name = row.elements[0].text
207 match = MouserKeyRegex.match(name)
208 raise "Unexpected table key #{name.inspect}" unless match
209 key = match[1]
210
211 value = row.elements[1].text
212 match = MouserValueRegex.match(value)
213 raise "Unexpected table value #{value.inspect}" unless match
214 value = match[1]
215 r[key] = value
216 end
217 r
218end
219
220def digikey_table_to_map table
221 raise "Unexpected HTML" unless table.name == 'table'
222 r = {}
223 table.elements.each do |row|
224 raise "Unexpected HTML" unless row.name == 'tr'
225 raise "Unexpected HTML" unless row.elements.length == 2
226
227 name, value = row.elements
228 raise "Unexpected HTML" unless name.name == 'th'
229 raise "Unexpected HTML" unless value.name == 'td'
230 r[name.text.gsub(Unicode::NBSP, '').strip] = value.text.gsub(Unicode::NBSP, '').strip
231 end
232 r
233end
234
235SimpleAttributes = [:pn, :value, :footprint, :device, :tolerance, :pn_optional, :dev_only]
236
237filenames.each do |filename|
238 puts filename
239 file = GschemSchematic.new(filename)
240 file.components.each do |component|
241 next if component.is_power
242 next if component[:graphical]
243
244 simple = {}
245 SimpleAttributes.each do |attr|
246 simple[attr] = component[attr]
247 end
248 case component[:device]
249 when 'CAPACITOR'
250 simple[:secondary_value] = component[:voltage]
251 when 'POLARIZED_CAPACITOR'
252 simple[:secondary_value] = component[:voltage]
253 when 'RESISTOR'
254 simple[:secondary_value] = component[:power]
255 end
256
257 symbol_filename = "schematic/symbols/#{component.filename}"
258 if File.exists? symbol_filename
259 symbol = GschemSymbol.new(symbol_filename)
260 if symbol[:device]
261 if component[:pn]
262 if !component[:footprint]
263 puts "#{component.refdes} has a pn and its symbol has a device but it has no footprint."
264 end
265 else
266 simple[:pn] = symbol[:device]
267 end
268 end
269
270 SimpleAttributes.each do |attr|
271 if symbol[attr]
272 if component[attr]
273 if component[attr] != symbol[attr]
274 puts "#{component.refdes} overriding #{attr} from symbol."
275 puts "\t#{symbol[attr]} => #{component[attr]}"
276 end
277 else
278 simple[attr] = symbol[attr]
279 end
280 end
281 end
282 end
283
284 add_part(simple, component.refdes)
285 end
286end
287
288# An array of arrays where the first one is the English case code and the second
289# is the metric one.
290MetricCases = [['0402', '1005'], ['0603', '1608'], ['0805', '2012'],
291 ['1206', '3216'], ['1210', '3225'], ['1812', '4532']]
292SimpleCaseRegex = /^\d\d\d\d$/
293
294def retrieve_mouser_package attributes
295 raw = attributes['Package / Case']
296 r = nil
297 if raw
298 if SimpleCaseRegex =~ raw
299 r = set_or_same r, raw
300 else
301 t = nil
302 MetricCases.each do |pair|
303 if /^#{pair[0]} \(#{pair[1]} [mM]etric\)$/ =~ raw
304 t = set_or_same r, pair[0]
305 end
306 end
307 r = set_or_same r, t || raw
308 end
309 end
310 r = set_or_same r, attributes['Case Code - in']
311
312 unless r
313 return nil
314 end
315
316 $parts_yaml['footprint_mappings'][r] || r
317end
318
319class PartInfo
320 Price = Struct.new(:quantity, :price, :link, :dpn)
321
322 attr_reader :part
323
324 attr_accessor :case_package
325 attr_accessor :component_value
326 attr_accessor :component_secondary_value
327 attr_accessor :component_value_tolerance
328
329 attr_accessor :order_quantity
330
331 # value_field is the field name for Octopart.
332 # value_unit is the units Octopart has.
333 # value_unit_suffix is the suffix in the .sch file.
334 attr_accessor :value_field, :value_unit, :value_unit_suffix
335 # mouser_value_unit_suffix is the suffix Mouser has on the value.
336 attr_accessor :mouser_value_field, :mouser_value_unit_suffix
337 attr_accessor :digikey_value_field, :digikey_value_unit_suffix
338 attr_accessor :secondary_value_field, :secondary_value_unit
339 attr_accessor :digikey_secondary_value_field, :mouser_secondary_value_field
340 attr_accessor :mouser_secondary_value_field
341
342 attr_accessor :manufacturer
343
344 def initialize part
345 @part = part
346
347 @prices = []
348 @descriptions = []
349 end
350
351 def add_description d
352 @descriptions.push d
353 end
354 def description
355 raise "No description for #{part}" if @descriptions.empty?
356 @descriptions[0]
357 end
358
359 def set_or_same field, value
360 name = '@' + field.to_s
361 old = instance_variable_get name
362 if value
363 if old
364 if value.class == Float && old.class == Float
365 eq = float_eq old, value
366 else
367 eq = old == value
368 end
369 if !eq
370 raise "#{old.inspect} (old) != #{value.inspect} (new) for #{part.inspect}"
371 end
372 else
373 instance_variable_set name, value
374 return value
375 end
376 end
377 return old
378 end
379
380 def add_price price
381 @prices.push price
382 end
383 def price_for quantity
384 return nil, nil if @prices.empty?
385 lowest_info = nil
386 lowest_price = Float::INFINITY
387 @prices.each do |price|
388 c = price.price * (quantity.to_f / price.quantity.to_f).ceil * price.quantity
389 if c < lowest_price
390 lowest_info = price
391 lowest_price = c
392 end
393 end
394 return lowest_price, lowest_info unless lowest_info
395 if lowest_info.quantity.to_f / quantity.to_f > 5 && (lowest_info.quantity - quantity) > 50
396 puts "Warning: no good price for #{quantity} of #{part.inspect}"
397 puts "\thave #{@prices.inspect}"
398 end
399 return lowest_price, lowest_info
400 end
401end
402
403def strip_from_end suffix, raw
404 if suffix.class == Regexp
405 match = suffix.match raw
406 raise "#{raw.inspect} does not match #{suffix}" unless match
407 match[1]
408 else
409 raise "#{raw.inspect} does not end with #{suffix}" unless raw.end_with? suffix
410 raw[0..-(suffix.length + 1)]
411 end
412end
413
414def make_digikey_request part, info
415 pn = $parts_yaml['digikey_numbers'][part.pn] || part.pn
416 page = "http://www.digikey.com/product-search/en?keywords=#{URI.escape pn}"
417 page = $parts_yaml[part.pn]['digikey_url'] || page if $parts_yaml[part.pn]
418 results = Nokogiri::HTML(retrieve_url(page))
419 search_results_html = results.css '#productTable > tbody'
420 values_table_html = results.css '.attributes-table-main'
421 prices_table = results.css '#pricing'
422 product_details = results.css '.product-details'
423 if !results.css(':contains("No records match your search criteria")').empty?
424 # No results from Digikey. Skip it.
425 elsif !values_table_html.empty?
426 raise "Unexpected HTML" unless values_table_html.length == 1
427 raise "Unexpected HTML" unless prices_table.length == 1
428 raise "Unexpected HTML" unless product_details.length == 1
429 parse_digikey_results values_table_html[0].elements[0], prices_table[0],
430 product_details[0], page, info
431 elsif !search_results_html.empty?
432 raise "Unexpected HTML" unless search_results_html.length == 1
433 found = false
434 search_results_html.children.each do |result|
435 if result.elements.length == 0
436 next
437 end
438 raise "Unexpected HTML" unless result.elements.length >= 5
439 links = result.elements[4].css 'a'
440 raise "Unexpected HTML" unless links.length == 1
441 mpn = links[0].text.strip
442 if mpn == part.pn
443 found = true
444
445 actual_page = 'http://www.digikey.com' + links[0]['href']
446 new_results = Nokogiri::HTML(retrieve_url(actual_page))
447 new_values_table = new_results.css '.attributes-table-main'
448 new_prices_table = new_results.css '#pricing'
449 new_product_details = new_results.css '.product-details'
450 new_feedback = results.css '#product-details-feedback'
451 unless new_feedback.empty?
452 if new_feedback.text == 'Obsolete item; call Digi-Key for more information.'
453 next
454 else
455 puts "Warning: Digikey gave feedback #{new_feedback}"
456 end
457 end
458 raise "Unexpected HTML" unless new_values_table.length == 1
459 raise "Unexpected HTML" unless new_prices_table.length == 1
460 raise "Unexpected HTML" unless new_product_details.length == 1
461 parse_digikey_results new_values_table[0].elements[0],
462 new_prices_table[0], new_product_details[0],
463 actual_page, info
464 end
465 end
466
467 if !found
468 puts "Warning: Got multiple Digikey search results for #{part.inspect}, none of them right."
469 end
470 else
471 raise "Don't know what Digikey page #{page} => #{results} for #{part.inspect} contains!"
472 end
473end
474
475def make_mouser_request part, info
476 pn = $parts_yaml['mouser_numbers'][part.pn] || part.pn
477 url = "http://www.mouser.com/Search/Refine.aspx?Keyword=#{URI.escape pn}"
478 mouser_results = Nokogiri::HTML(retrieve_url(url))
479 search_results_html = mouser_results.css '#ctl00_ContentMain_SearchResultsGrid_grid'
480 values_table_html = mouser_results.css '.specs'
481 prices_table = mouser_results.css '.PriceBreaks'
482 details_table = mouser_results.css '#product-desc'
483 if !mouser_results.css(':contains("did not return any results.")').empty?
484 # No results from Mouser. Skip it.
485 elsif !values_table_html.empty?
486 parse_mouser_results values_table_html, prices_table, details_table, url, info
487 elsif !search_results_html.empty?
488 found = false
489 search_results_html.children[2..-1].each do |result|
490 if result.elements.length == 0
491 next
492 end
493 raise "Unexpected HTML" unless result.elements.length >= 4
494 links = result.elements[3].css 'a'
495 raise "Unexpected HTML" unless links.length >= 1
496 mpn = links.first.text.strip
497 if mpn == part.pn
498 if result.elements[2].text.strip == 'Not Assigned'
499 # This means that Mouser doesn't actually carry it and probably
500 # doesn't have much data on it, none of which I want to trust.
501 next
502 end
503 found = true
504
505 actual_page = 'http://www.mouser.com/Search/' + links[0]['href']
506 mouser_results = Nokogiri::HTML(retrieve_url(actual_page))
507 values_table_html = mouser_results.css '.specs'
508 prices_table = mouser_results.css '.PriceBreaks'
509 details_table = mouser_results.css '#product-desc'
510 parse_mouser_results values_table_html, prices_table, details_table, actual_page, info
511 end
512 end
513 if !found
514 puts "Warning: Got multiple Mouser search results for #{part.inspect}, none of them right."
515 end
516 else
517 raise "Don't know what Mouser page #{url} => #{mouser_results} contains!"
518 end
519end
520
521WeirdFractionRegex = /^([0-9.]+ [a-zA-Z]+) \([0-9]+\/[0-9]+ [a-zA-Z]+\)$/
522
523def parse_digikey_results values_table_html, prices_table, product_details, url, info
524 attributes = digikey_table_to_map values_table_html
525
526 digikey_package = retrieve_mouser_package attributes
527 if digikey_package != 'Non Standard' && digikey_package != 'Nonstandard'
528 info.set_or_same :case_package, digikey_package
529 end
530
531 if info.digikey_value_field && attributes[info.digikey_value_field]
532 v = strip_from_end info.digikey_value_unit_suffix, attributes[info.digikey_value_field]
533 info.set_or_same :component_value, to_f_with_suffix(v)
534 end
535 if info.digikey_secondary_value_field && attributes[info.digikey_secondary_value_field]
536 v = strip_from_end info.secondary_value_unit, attributes[info.digikey_secondary_value_field]
537 info.set_or_same :component_secondary_value, to_f_with_suffix(v)
538 end
539
540 if attributes['Tolerance']
541 if attributes['Tolerance'] == '-20%, +80%'
542 info.set_or_same :component_value_tolerance, 80
543 else
544 v = strip_from_end '%', attributes['Tolerance'].gsub(Unicode::PlusMinus, '')
545 info.set_or_same :component_value_tolerance, v.to_f
546 end
547 end
548
549 distributor_row = product_details.elements[2]
550 raise "Unexpected HTML" unless distributor_row.elements[0].text == 'Digi-Key Part Number'
551 description_row = product_details.elements[6]
552 raise "Unexpected HTML" unless description_row.elements[0].text == 'Description'
553
554 info.add_description description_row.elements[1].text.chomp
555
556 prices_table.elements[1..-1].each do |price|
557 quantity = price.elements[0].text.gsub(',', '').to_i
558 raise unless quantity.to_s == price.elements[0].text.gsub(',', '')
559 price_number = price.elements[1].text.gsub(',', '').to_f
560 raise unless chomp_zeros(price_number.to_s) == chomp_zeros(price.elements[1].text)
561 info.add_price PartInfo::Price.new(quantity, price_number, url,
562 distributor_row.elements[1].text)
563 end
564
565 manufacturer_row = product_details.elements[4]
566 raise "Unexpected HTML" unless manufacturer_row.elements[0].text == 'Manufacturer'
567 manufacturer = manufacturer_row.elements[1].text
568 manufacturer = $parts_yaml['manufacturer_names'][manufacturer] || manufacturer
569 info.set_or_same :manufacturer, manufacturer
570end
571
572def parse_mouser_results values_table_html, prices_table, details_table, url, info
573 attributes = mouser_table_to_map values_table_html
574
575 mouser_package = retrieve_mouser_package attributes
576 if info.case_package == 'SRR1210' && mouser_package == '1210'
577 elsif info.case_package == '0612' && mouser_package == '1206'
578 else
579 info.set_or_same :case_package, mouser_package
580 end
581
582 if info.mouser_value_field && attributes[info.mouser_value_field]
583 v = strip_from_end info.mouser_value_unit_suffix, attributes[info.mouser_value_field]
584 info.set_or_same :component_value, to_f_with_suffix(v)
585 end
586 if info.mouser_secondary_value_field && attributes[info.mouser_secondary_value_field]
587 v = attributes[info.mouser_secondary_value_field]
588 match = WeirdFractionRegex.match v
589 if match
590 v = match[1]
591 end
592 v = strip_from_end info.secondary_value_unit, v
593 info.set_or_same :component_secondary_value, to_f_with_suffix(v)
594 end
595
596 if info.part.device != 'CRYSTAL' && attributes['Tolerance']
597 v = strip_from_end '%', attributes['Tolerance'].gsub('+/-', '')
598 info.set_or_same :component_value_tolerance, v.to_f
599 end
600
601 raise "Unexpected HTML" unless details_table.length == 1
602 details_table_contents = {}
603 if details_table[0].elements.size > 1
604 details_table.first.elements.each do |row|
605 details_table_contents[row.elements[0].text.strip] =
606 row.elements[1].text.strip
607 end
608 else
609 details_table.first.elements.first.elements.each do |row|
610 details_table_contents[row.elements.first.text.strip] =
611 row.elements[1].text.strip
612 end
613 end
614 details_table_contents.each do |_, v|
615 if v == 'Obsolete'
616 return
617 end
618 end
619
620 manufacturers = details_table_contents['Manufacturer:'].lines.collect do |line|
621 line.strip
622 end.delete_if do |line|
623 line.empty?
624 end.uniq
625 unless manufacturers.length == 1
626 raise "Not sure what manufacturers #{manufactures.inspect} from Mouser mean"
627 end
628 manufacturer = manufacturers[0]
629 manufacturer = $parts_yaml['manufacturer_names'][manufacturer] || manufacturer
630 info.set_or_same :manufacturer, manufacturer
631
632 distributor = details_table_contents['Mouser Part #:']
633 if distributor == 'Not Assigned'
634 return
635 end
636 raise "Unexpected HTML" unless prices_table.length == 1
637
638 info.add_description details_table_contents['Description:'].chomp
639
640 prices_table[0].elements.each do |row|
641 raise "Unexpected HTML" unless row.name == 'div' || row.name == 'tr'
642 if row.elements.length == 4
643 quantity_text = row.elements[1].elements[0].text
644 price_text = row.elements[2].text.strip
645 if price_text == 'Quote>'
646 next
647 elsif price_text == ''
648 # There is (sometimes?) an empty row at the bottom.
649 raise "Huh?" unless quantity_text == ''
650 next
651 elsif !price_text.start_with? '$'
652 puts row
653 raise "Don't know how to interpret Mouser price #{price_text.inspect}."
654 end
655 quantity = quantity_text.gsub(',', '').to_i
656 raise unless quantity.to_s == quantity_text.gsub(',', '')
657 price = price_text[1..-1].gsub(',', '').to_f
658 raise unless chomp_zeros(price.to_s) == chomp_zeros(price_text[1..-1])
659 info.add_price PartInfo::Price.new(quantity, price, url, distributor)
660 end
661 end
662end
663
664def parse_value f, unit
665 raise "Don't know what min/max values mean." if f['min_value'] || f['max_value']
666 v = f['value'][0]
667 if unit == '%'
668 raise "Want %, not units" if f['metadata']['unit']
669 if v == '+80/-20%'
670 v = '80'
671 else
672 v = strip_from_end '%', v.gsub(Unicode::PlusMinus, '')
673 end
674 else
675 if f['metadata']['unit']['name'] != unit
676 raise "Wrong units on #{f.inspect} (expected #{unit})."
677 end
678 end
679 raise "Don't know what multiple values #{f['value'].uniq} mean." if f['value'].uniq.size != 1
680 r = v.to_f
681 raise "Error parsing #{v}" if r == 0.0
682 r
683end
684
685SiSuffixes = {'M' => 1e6, 'k' => 1e3, 'm' => 1e-3, 'u' => 1e-6, 'n' => 1e-9,
686 'p' => 1e-12, 'f' => 1e-15,
687 Unicode::Micro => 1e-6}
688
689# Matches Digikey-style resistor power ratings like "0.063W, 1/16".
690FractionalWattsRegex = /^([0-9.]+)W, ([0-9]+)\/([0-9]+)$/
691
692def to_f_with_suffix raw
693 raw.rstrip!
694 suffix_power = 1
695 SiSuffixes.each do |suffix, power|
696 if raw.end_with? suffix
697 raw = raw[0..-2]
698 suffix_power *= power
699 end
700 end
701 raw.rstrip!
702
703 fractional_watts = FractionalWattsRegex.match(raw)
704 if fractional_watts
705 raise "Fraction and power for #{raw.inspect}?" unless suffix_power == 1
706 decimal = fractional_watts[1].to_f
707 fraction = fractional_watts[2].to_f / fractional_watts[3].to_f
708 if (decimal - fraction).abs > 10 ** -(fractional_watts[1].length - 2)
709 raise "Mismatched fraction #{fraction} and decimal #{decimal}"
710 end
711 return decimal
712 end
713
714 raise "Can't parse number #{raw.inspect}" if /^[0-9.]+$/ !~ raw
715
716 raw.to_f * suffix_power
717end
718
719def float_eq a, b
720 ((a - b).abs / [a.abs, b.abs].max) < 1e-15
721end
722
723def check_value_attribute raw, suffix, expected
724 raw = strip_from_end suffix, raw
725 found_value = to_f_with_suffix raw
726 float_eq expected, found_value
727end
728
729total_price = 0
730total_per_item_price = 0
731
732$parts.each do |_, part|
733 info = PartInfo.new part
734
735 unit_price = nil
736 order_quantity = nil
737 if $bom_file
738 footprint = $parts_yaml['bom_footprints'][part.footprint] || part.footprint
739 case options[:bom_style]
740 when :myro
741 line = [part.quantity,
742 part.refdeses.join(' '),
743 part.pn,
744 info.manufacturer,
745 lowest_price_info ? lowest_price_info.dpn : 'NA',
746 info.description.gsub('"', ''),
747 footprint,
748 dnp ? 'DNI' : ''].collect do |o|
749 o.to_s.inspect
750 end.join(',')
751 when :mouser
752 if lowest_price_info.link.include?('mouser.com')
753 #line = "#{part.pn.inspect},#{order_quantity},#{info.description.gsub('"', '').inspect}"
754 elsif lowest_price_info.link.include?('digikey.com')
755 line = "#{lowest_price_info.dpn},#{order_quantity}"
756 else
757 raise
758 end
759 else
760 line = [part.quantity,
761 part.refdeses.join(' '),
762 part.pn,
763 footprint,
764 part.value,
765 unit_price].collect do |o|
766 o.to_s.inspect
767 end.join(',')
768 end
769 $bom_file.puts line if line
770 end
771end
772puts total_price
773puts total_per_item_price
774
775$bom_file.close if $bom_file