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