Make the component library a standalone product door (2.63.0).
Add /api/library datasheet import and component GET/PUT with no exam. Reorganize pytest into datasheet, library, schematic, PCB, and AF+AI. Document Rust criteria (none chosen; no rustup) and coding conformity.
This commit is contained in:
@@ -0,0 +1,663 @@
|
|||||||
|
# Albero delle analisi Periscope
|
||||||
|
|
||||||
|
Versione codice **2.63.0** (`periscope/src/frontend/content/changelog.md`). Letto in `/Users/michelebigi/Development/periscope`. Costituzione senza eccezioni: via ≠ pad ≠ traccia ≠ zona; niente I/Z/mm inventati; skip se manca evidenza. **Non è DRC KiCad.** DRC = KiCad. Questo documento mappa l’analisi, non implementa.
|
||||||
|
|
||||||
|
I test pytest stanno in `tests/datasheet/`, `tests/library/`, `tests/schematic/`, `tests/pcb/`, `tests/af_ai/` (stessi file di 2.62.1, cartelle di mestiere). I path `tests/test_*.py` sotto i nodi restano i nomi file.
|
||||||
|
|
||||||
|
Due pipeline:
|
||||||
|
|
||||||
|
1. **Schematico / review AI** — `MODE=run`, `services/pipeline.py` → `validate_design_async` (`validation.py`): estrazione datasheet, check deterministici, review IC DeepSeek.
|
||||||
|
2. **Esame PCB** — `MODE=pcb`, `services/pcb_pipeline.py` → `run_pcb_checks` (`pcb_checks.py`) + review AI PCB (`pcb_review.py` / `pcb_validation.py`). Non auto-place.
|
||||||
|
|
||||||
|
I certifier USB-C / Ethernet / PoE girano su **entrambe** le pipeline (grafo, non geometria). DDR / CPU / FPGA / USB-PD sono la stessa qualità di finding ma partono da `run_pcb_checks` (grafo). Niente pezzo sul grafo → silenzio, non N/A.
|
||||||
|
|
||||||
|
Nodi analisi: **108** (99 PE-* + 1 LED senza `rule_id` + 8 processo estrazione/AI). Test pytest citati sotto i nodi: nomi reali da `tests/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
```
|
||||||
|
Periscope 2.62.1
|
||||||
|
├── 1. Schematico / review AI (MODE=run)
|
||||||
|
│ ├── ingest (non è analisi: parser → grafo)
|
||||||
|
│ ├── estrazione datasheet
|
||||||
|
│ ├── check deterministici schema
|
||||||
|
│ ├── certifier di classe (condivisi con PCB)
|
||||||
|
│ └── review AI per-IC + post-pass
|
||||||
|
└── 2. Esame PCB (MODE=pcb)
|
||||||
|
├── ingest layout (non è DRC)
|
||||||
|
├── run_pcb_checks
|
||||||
|
│ ├── net / footprint
|
||||||
|
│ ├── placement vs layout_rules
|
||||||
|
│ ├── SI + linee HF (2.62.0 Fase A)
|
||||||
|
│ ├── DDR / CPU / FPGA (2.62.0)
|
||||||
|
│ ├── potenza / via / termico
|
||||||
|
│ ├── derating / timing / PI
|
||||||
|
│ ├── ESD / return / EMI / SPOF
|
||||||
|
│ ├── BOM ↔ PCB ↔ datasheet
|
||||||
|
│ └── resto Fase B (2.62.1)
|
||||||
|
├── review AI PCB
|
||||||
|
└── tab antenna RF (non in run_pcb_checks)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Schematico / review AI (`MODE=run`)
|
||||||
|
|
||||||
|
Orchestrazione: `pipeline.py` stadi `bom_parse` → `ic_extraction` → `simple_extraction` → `passive_extraction` → `graph_build` → `validation`. Check deterministici: `_run_deterministic_checks` in `validation.py`. Finding ID schema `PS-{ref}-{nnn}` (non `PCB-`).
|
||||||
|
|
||||||
|
### 1.1 Ingest (prerequisito, non analisi)
|
||||||
|
|
||||||
|
Parser e grafo non emettono PE-*. Skip MPN: `ic_mpn_skip_reason` (silenzio onesto, niente MPN inventato).
|
||||||
|
|
||||||
|
- Test parser/grafo: `tests/test_bom_parser.py` (`test_parse_bom_uses_value_for_ic_when_mpn_column_empty`, `test_parse_bom_keeps_pnm_and_datasheet_url`); `tests/test_hubaudio_bom.py` (tutti e 4); `tests/test_netlist_parser.py` (tutti e 3); `tests/test_edif_parser.py` (14); `tests/test_kicad_parser.py` (17); `tests/test_kicad_project.py` (19); `tests/test_netlist_bundle.py` (9); `tests/test_functional_groups.py` (8).
|
||||||
|
- Test overlay parser: `tests/test_periscope_parsers_rewrite.py`, `tests/test_periscope_graph_rewrite.py`, `tests/test_periscope_models_rewrite.py`.
|
||||||
|
|
||||||
|
### 1.2 Estrazione datasheet (processo)
|
||||||
|
|
||||||
|
Skip se manca PDF o lo skill non ha numero. Libreria `library/extracted` condivisa schema+PCB. PCB review non rilegge il PDF (`pdf_path=None`).
|
||||||
|
|
||||||
|
#### `extract-pintable` — skill `skills/extract-pintable/`
|
||||||
|
|
||||||
|
Modulo: `services/datasheet_extract.py` + `llm/local_skill.py`. Analizza: pin table, package, taxonomy IC.
|
||||||
|
|
||||||
|
- Skip se: niente PDF; IC già in libreria alla `model_version` minima.
|
||||||
|
- Test: `tests/test_native_datasheet_extract.py` (`test_coerce_abs_max_matches_inherited`, `test_native_extract_has_no_anthropic_import`); `tests/test_pdf_ingest.py` (6); `tests/test_deepseek_provider.py` (`test_local_skills_load`, `test_extract_pdf_text_includes_page_markers`, `test_wroom_rejects_bare_soc_pin1_ant`); `tests/test_periscope_taxonomy_skills_src.py` (`test_pintable_skill_mentions_save_tool_and_wroom`, `test_skills_dir_is_src_and_local`).
|
||||||
|
|
||||||
|
#### `extract-specs` — skill `skills/extract-specs/`
|
||||||
|
|
||||||
|
Analizza: specs discreti/connettori/cristalli; parametri fuori taxonomy → scartati.
|
||||||
|
|
||||||
|
- Skip se: niente PDF o tipo non in `SIMPLE_TYPES`.
|
||||||
|
- Test: `tests/test_pdf_ingest.py` (`test_coerce_abs_max_keeps_valid_drops_junk`); `tests/test_layout_rules.py` (5 — `layout_rules` numerici, niente mm inventati).
|
||||||
|
|
||||||
|
#### `extract-pattern` — skill `skills/extract-pattern/`
|
||||||
|
|
||||||
|
Analizza: pattern MPN passivi (R/C/L/FB).
|
||||||
|
|
||||||
|
- Skip se: MPN opaco; DigiKey/value fallback copre prima.
|
||||||
|
- Test: `tests/test_inductor_specs.py` (13, Z ferrite solo se il PDF dice N Ω @ freq); `tests/test_pcb_software_bugs.py` (`test_lqw_decoder_and_unknown_skip`, `test_fb1_blm_is_bead_not_dcr_resistor`).
|
||||||
|
|
||||||
|
#### Resolve DigiKey / catalogo (esatto MPN)
|
||||||
|
|
||||||
|
Modulo: `services/digikey.py`, `passive_from_distributor.py`, `passive_from_mpn.py`. Analizza: parametri catalogo per auto-resolve. Hit fuzzy rifiutati.
|
||||||
|
|
||||||
|
- Skip se: MPN non esatto; API assente (fail-soft).
|
||||||
|
- Test: `tests/test_digikey_match.py` (3); `tests/test_passive_from_distributor.py` (7); `tests/test_passive_from_mpn.py` (7); `tests/test_datasheet_finder.py` (17); `tests/test_library.py` (5).
|
||||||
|
|
||||||
|
#### Resolve da stringa Value (solo progetto)
|
||||||
|
|
||||||
|
Modulo: `services/passive_from_value.py`. Specs value-derived **non** in libreria condivisa.
|
||||||
|
|
||||||
|
- Skip se: stringa ambigua / placeholder.
|
||||||
|
- Test: `tests/test_passive_from_value.py` (8).
|
||||||
|
|
||||||
|
### 1.3 Check deterministici schema
|
||||||
|
|
||||||
|
Fail-soft per modulo. Senza evidenza: skip (lista vuota), non folklore.
|
||||||
|
|
||||||
|
#### `PE-MUX-001` — `pin_mux_check.py`
|
||||||
|
|
||||||
|
Analizza: funzione pin vs net (UART/SPI/I2C mux).
|
||||||
|
Skip se: `functions` vuote; link inter-device stesso peripheral; net opaco.
|
||||||
|
|
||||||
|
- Test: `tests/test_pin_mux_check.py` — `test_real_defect_uart5_swapped_is_error`, `test_correct_assignment_no_finding`, `test_inter_device_same_peripheral_link_is_skipped`, `test_transceiver_peer_without_peripheral_still_fires`, `test_empty_functions_skipped`, `test_pin_exposes_peripheral_but_not_signal_no_complement`, `test_opaque_net_not_flagged`, `test_token_parser_and_normalizer`, `test_spi_legacy_net_names_match_modern_pin_functions`, `test_spi_controller_peripheral_names_are_synonyms`, `test_simple_project_uart0_nets_are_feasible_on_mspm0_pins`, `test_simple_project_uart0_swapped_on_mspm0_is_error`, `test_spi_genuine_infeasibility_still_fires_with_modern_names`, `test_finding_prints_full_raw_capability_list_and_intent_caveat`, `test_legacy_report_without_source_validates`.
|
||||||
|
|
||||||
|
#### `PE-NC-001` — `nc_pin_check.py`
|
||||||
|
|
||||||
|
Analizza: pin NC collegato a net attivo.
|
||||||
|
Skip se: niente pintable.
|
||||||
|
|
||||||
|
- Test: `tests/test_nc_pin_check.py` — `test_nc_pin_on_active_net_warns`, `test_nc_pin_on_nc_net_silent`, `test_no_pintable_silent`.
|
||||||
|
|
||||||
|
#### `PE-DEC-001` / `PE-DEC-002` — `passive_rail_check.py` `check_supply_decoupling`
|
||||||
|
|
||||||
|
Analizza: condensatore sul net di alimentazione IC (001 presenza; 002 valore vs datasheet).
|
||||||
|
Skip se: net NC; pin enable strap; manca valore per 002.
|
||||||
|
|
||||||
|
- Test: `tests/test_passive_rail_check.py` — `test_missing_decoupling_is_warning`, `test_cap_to_gnd_clears_decoupling`, `test_enable_strapped_to_rail_is_not_decoupling`, `test_nc_supply_net_is_skipped`, `test_ldo_vout_needs_cout`, `test_ldo_vout_100n_only_is_value_warning`, `test_vdd_100n_is_not_a_value_warning`, `test_ki_cad_voltage_prefix_is_power`, `test_fb_and_rn_prefixes`.
|
||||||
|
|
||||||
|
#### `PE-I2C-001` / `PE-I2C-002` — `passive_rail_check.py` `check_i2c_pullups`
|
||||||
|
|
||||||
|
Analizza: pull-up I2C (001 presenza; 002 valore vs banda).
|
||||||
|
Skip se: pin SDA è alias SPI; pull-up senza valore (002 non sizato); bus assente.
|
||||||
|
|
||||||
|
- Test: `tests/test_passive_rail_check.py` — `test_i2c_missing_pullup`, `test_i2c_pullup_present`, `test_i2c_pullup_to_3v3_digital_typed_as_signal`, `test_spi_pin_alias_sda_is_not_i2c`, `test_i2c_from_slash_alias_in_pin_name`, `test_i2c_4k7_pullup_is_in_nxp_wide_band`, `test_i2c_100ohm_pullup_is_too_stiff`, `test_i2c_100k_pullup_is_too_weak`, `test_i2c_pullup_without_value_is_not_sized`.
|
||||||
|
|
||||||
|
#### `PE-RST-001` / `PE-RST-002` — `passive_rail_check.py` `check_reset_pullups`
|
||||||
|
|
||||||
|
Analizza: reset/enable pull (001 presenza; 002 verso vs datasheet).
|
||||||
|
Skip se: GPIO guida il reset.
|
||||||
|
|
||||||
|
- Test: `tests/test_passive_rail_check.py` — `test_reset_no_finding_when_gpio_drives`, `test_reset_floating_is_warning`, `test_nrst_pulldown_is_warning`, `test_nrst_pullup_is_not_pulldown`.
|
||||||
|
|
||||||
|
#### `PE-BOM-001` / `PE-BOM-002` — `bom_match_check.py`
|
||||||
|
|
||||||
|
Analizza: MPN schema vs BOM (001 mismatch; 002 riga BOM orfana).
|
||||||
|
Skip se: mappa schematica vuota (PADS/EDIF senza properties).
|
||||||
|
|
||||||
|
- Test: `tests/test_bom_match_check.py` — `test_matching_mpns_produce_no_findings`, `test_mpn_mismatch_is_error_ps_bom_001`, `test_orphan_bom_ref_is_warning`, `test_mpn_case_and_whitespace_are_not_a_mismatch`, `test_empty_bom_mpn_with_schematic_mpn_is_not_a_mismatch`, `test_empty_schematic_map_skips_check`, `test_legacy_design_graph_without_source_fields_still_validates`, `test_build_graph_kicad_mpn_mismatch_surfaces`.
|
||||||
|
|
||||||
|
#### `PE-DNP-001` — `dnp_check.py`
|
||||||
|
|
||||||
|
Analizza: DNP vs enable floating.
|
||||||
|
Skip se: colonna DNP assente.
|
||||||
|
|
||||||
|
- Test: `tests/test_dnp_check.py` — `test_dnp_pull_leaves_enable_floating`, `test_fitted_pull_is_silent`, `test_no_dnp_column_skips_floating_enable`, `test_parse_bom_reads_dnp_and_skips_when_column_absent`.
|
||||||
|
|
||||||
|
#### LED corrente — `led_current_check.py` (nessun `PE-*`)
|
||||||
|
|
||||||
|
Analizza: I_f Ohm vs rating canale. Source `led_current_check`.
|
||||||
|
Skip se: niente `forward_current_*`; tensione rail sconosciuta (non si indovina).
|
||||||
|
|
||||||
|
- Test: `tests/test_led_current_check.py` — `test_over_current_is_error`, `test_proper_resistor_no_finding`, `test_unknown_rail_no_error`, `test_no_rating_skipped`, `test_parse_resistance`.
|
||||||
|
|
||||||
|
#### `PE-TH-001` / `PE-TH-002` / `PE-TH-003` — `thermal_check.py`
|
||||||
|
|
||||||
|
Analizza: P = I_load × drop; Tj = Ta + P·θJA (001 INFO senza θJA o Tj ok; 002 WARNING Tj alto — **non in catalogo `finding_engine`**; 003 I²R shunt).
|
||||||
|
Skip se: manca I_load (Iout_max non è carico).
|
||||||
|
|
||||||
|
- Test: `tests/test_thermal_check.py` — `test_ldo_without_theta_ja_is_info`, `test_iout_max_is_not_used_as_load`, `test_ldo_hot_tj_is_warning`, `test_shunt_over_rating_is_warning`, `test_led_resistor_within_rating_is_silent`.
|
||||||
|
|
||||||
|
#### `PE-PWR-001` (schema) — `power_margin_check.py`
|
||||||
|
|
||||||
|
Analizza: Σ IQ + I_load vs Iout regolatore; caduta su R serie. Source `power_margin_check`.
|
||||||
|
Skip se: manca I_load; IQ assente non è fail.
|
||||||
|
|
||||||
|
- Test: `tests/test_power_margin_check.py` — `test_load_over_iout_max_is_ps_pwr_001`, `test_missing_iq_is_not_guessed_into_margin_fail`, `test_series_r_ir_drop_uses_i_load_not_trace`, `test_no_series_r_does_not_invent_trace_drop`.
|
||||||
|
|
||||||
|
#### `PE-SEQ-001` — `sequencing_check.py`
|
||||||
|
|
||||||
|
Analizza: PG → EN da note datasheet.
|
||||||
|
Skip se: niente spec `power_sequence`.
|
||||||
|
|
||||||
|
- Test: `tests/test_sequencing_check.py` — `test_pg_not_tied_to_en_is_warning`, `test_pg_tied_to_en_is_silent`, `test_no_power_sequence_spec_skips_even_if_pg_open`.
|
||||||
|
|
||||||
|
#### `PE-FLT-001` / `PE-FLT-002` / `PE-FLT-003` — `filter_check.py`
|
||||||
|
|
||||||
|
Analizza: topologia filtro (001), pezzo (002), fc vs ADC / DCR ferrite (003).
|
||||||
|
Skip se: manca C; pull-up+decoupling non è filtro; DCR ferrite senza limite datasheet.
|
||||||
|
|
||||||
|
- Test: `tests/test_filter_check.py` — `test_rc_reports_fc_info_without_adc_rate`, `test_rc_vs_adc_rate_is_warning`, `test_pullup_plus_decoupling_is_not_a_filter`, `test_missing_c_value_does_not_invent_fc_warning`, `test_pi_and_t_need_l_and_c`, `test_ferrite_dcr_warns_only_with_datasheet_limit`.
|
||||||
|
|
||||||
|
#### `PE-XTAL-001` / `PE-XTAL-002` / `PE-XTAL-003` — `crystal_cl_check.py`
|
||||||
|
|
||||||
|
Analizza: CL vs C1/C2 (001 coppia; 002 serie/stray; 003 drive). Cstray solo se in specs.
|
||||||
|
Skip se: niente CL in specs.
|
||||||
|
|
||||||
|
- Test: `tests/test_crystal_cl_check.py` — `test_no_cl_in_specs_is_silent`, `test_series_above_cl_without_stray_warns`, `test_with_stray_mismatch_warns`, `test_simple_project_without_cl_silent`.
|
||||||
|
|
||||||
|
#### `PE-LF-001` / `PE-LF-002` / `PE-LF-003` — `lifecycle.py`
|
||||||
|
|
||||||
|
Analizza: Obsolete / NRND / RoHS non-compliant.
|
||||||
|
Skip se: catalogo assente.
|
||||||
|
|
||||||
|
- Test: `tests/test_lifecycle_check.py` — `test_obsolete_is_warning_and_uses_distributor_replacement`, `test_nrnd_is_info`, `test_explicit_rohs_non_compliant_is_warning`, `test_active_is_silent`, `test_rohs_not_applicable_is_not_a_fail`, `test_missing_catalog_and_missing_substitute_are_not_guessed`.
|
||||||
|
|
||||||
|
#### `PE-INT-001` — `internal_features_check.py`
|
||||||
|
|
||||||
|
Analizza: ESD/pull interni vs net.
|
||||||
|
Skip se: `internal_features` vuoto (non si indovina open-drain).
|
||||||
|
|
||||||
|
- Test: `tests/test_internal_features_check.py` — `test_listed_open_drain_without_pull_is_warning`, `test_listed_pin_with_pullup_is_silent`, `test_empty_features_does_not_guess_open_drain`.
|
||||||
|
|
||||||
|
#### `PE-ESR-001` — `hf_coverage_check.py`
|
||||||
|
|
||||||
|
Analizza: copertura HF/ESR (bulk vs 100 n).
|
||||||
|
Skip se: valore C sconosciuto.
|
||||||
|
|
||||||
|
- Test: `tests/test_hf_coverage_check.py` — `test_bulk_only_is_info_ps_esr_001`, `test_bulk_plus_100n_is_silent`, `test_unknown_cap_value_is_not_guessed`.
|
||||||
|
|
||||||
|
#### `PE-ERRATA-001` — `errata_check.py`
|
||||||
|
|
||||||
|
Analizza: errata pubblicata vs pull sul pin.
|
||||||
|
Skip se: MPN sconosciuto o entry senza URL.
|
||||||
|
|
||||||
|
- Test: `tests/test_errata_check.py` — `test_missing_errata_pullup_is_ps_errata_001`, `test_pullup_present_is_silent`, `test_unknown_mpn_and_url_less_entry_are_silent`.
|
||||||
|
|
||||||
|
Suite deterministica end-to-end: `tests/test_validation_deterministic_seed.py` `test_deterministic_findings_seeded_and_not_reviewed`; `tests/test_eval_report.py` `test_simple_project_eval_matches_committed_golden`.
|
||||||
|
|
||||||
|
### 1.4 Certifier di classe (grafo — schema **e** PCB)
|
||||||
|
|
||||||
|
Moduli: `interface_class_check.py` (schema **e** PCB), `hf_bus_class.py` (2.62.0, solo `run_pcb_checks`), `usb_pd_check.py` (2.62.1, solo PCB). Niente geometria. Niente connettore/dispositivo → **silenzio**.
|
||||||
|
|
||||||
|
#### USB-C — solo se receptacle USB-C sul grafo
|
||||||
|
|
||||||
|
- **`PE-USBC-001`** classe USB2 vs USB3 da BOM/footprint/MPN/net.
|
||||||
|
- **`PE-USBC-002`** CC1 e CC2.
|
||||||
|
- **`PE-USBC-003`** Rd 5.1 kΩ ±10% o Rp 56/22/10 kΩ. Skip/INSUFFICIENT se R ignota o CC verso controller senza R.
|
||||||
|
- **`PE-USBC-004`** VBUS e GND.
|
||||||
|
- **`PE-USBC-005`** SuperSpeed solo se classe USB3; USB2 = N/A (non ERROR).
|
||||||
|
|
||||||
|
Test: `tests/test_interface_class_check.py` — `test_no_connector_emits_nothing`, `test_usbc_rd_vbus_gnd_certified`, `test_usbc_missing_cc2_is_error`, `test_usbc_wrong_rd_is_error`, `test_usbc_cc_to_controller_without_r_is_insufficient_not_error`, `test_usbc_unknown_r_value_is_insufficient`, `test_usb3_class_without_ss_pairs_is_error`, `test_usb3_with_ss_pairs_is_certified`, `test_missing_vbus_is_error`, `test_simple_project_usbc_not_false_error`, `test_hubaudio_like_usbc_usb2_and_poe_magjack`, `test_usbc_vbus_does_not_invent_usb_500ma`, `test_does_not_use_layout_geometry`, `test_rule_catalog_shared_not_si`.
|
||||||
|
|
||||||
|
#### Ethernet — solo se RJ45/MagJack
|
||||||
|
|
||||||
|
- **`PE-ETH-001`** classe 10/100 vs GbE. Skip/INSUFFICIENT se velocità assente (non si inventa GbE).
|
||||||
|
- **`PE-ETH-002`** TX/RX (10/100) o quattro MDI (GbE).
|
||||||
|
- **`PE-ETH-003`** magnetics se GbE.
|
||||||
|
- **`PE-ETH-004`** Bob Smith 75 Ω: REVIEW (spesso in MagJack).
|
||||||
|
|
||||||
|
Test: `tests/test_interface_class_check.py` — `test_ethernet_10_100_magjack_no_gbe_magnetics_error`, `test_gbe_without_magnetics_is_error`, `test_gbe_with_magnetics_is_certified`, `test_rj45_without_speed_is_insufficient_not_invented_gbe`.
|
||||||
|
|
||||||
|
#### PoE — solo con evidenza PoE (RJ45 nudo = skip)
|
||||||
|
|
||||||
|
- **`PE-POE-001`** evidenza PoE.
|
||||||
|
- **`PE-POE-002`** classe/tipo (802.3af/at/bt, Class n) — mai inventata.
|
||||||
|
- **`PE-POE-003`** magnetics/isolamento (MagJack o LAN transformer).
|
||||||
|
- **`PE-POE-004`** (2.62.1) tensione di isolamento solo da numero sourced. Skip senza V.
|
||||||
|
|
||||||
|
Test: `tests/test_interface_class_check.py` — `test_bare_rj45_does_not_invent_poe`, `test_poe_with_evidence_requires_magnetics_isolation`, `test_poe_evidence_on_bare_jack_is_isolation_error`, `test_hubaudio_bom_has_usbc_and_poe_rj45_not_ddr`, `test_run_pcb_checks_skips_absent_interfaces`; `tests/test_pcb_phase_b.py` — `test_poe_isolation_skips_without_voltage_fact`.
|
||||||
|
|
||||||
|
#### DDR — solo se DRAM sul grafo (2.62.0)
|
||||||
|
|
||||||
|
- **`PE-DDR-001`** classe DRAM.
|
||||||
|
- **`PE-DDR-002`** CK, DQS, ≥8 DQ.
|
||||||
|
- **`PE-DDR-003`** VTT/VREF solo se quei net esistono (altrimenti skip, non VTT inventato).
|
||||||
|
|
||||||
|
Test: `tests/test_hf_line_check.py` — `test_no_ddr_cpu_fpga_on_usb_only_graph`, `test_ddr_present_class_and_nets_skip_invented_vtt`, `test_rule_catalog_hf_ids`.
|
||||||
|
|
||||||
|
#### CPU bus parallelo — solo se D/A/controllo esistono (2.62.0)
|
||||||
|
|
||||||
|
- **`PE-CPU-001`** presenza bus parallelo.
|
||||||
|
- **`PE-CPU-002`** ≥8 D/DQ/AD.
|
||||||
|
- **`PE-CPU-003`** address o AD mux.
|
||||||
|
- **`PE-CPU-004`** CS/WE/OE (o FMC).
|
||||||
|
|
||||||
|
Test: `tests/test_hf_line_check.py` — `test_cpu_parallel_bus_only_when_data_addr_control_exist`.
|
||||||
|
|
||||||
|
#### FPGA — solo se FPGA sul grafo (2.62.0)
|
||||||
|
|
||||||
|
- **`PE-FPGA-001`** classe FPGA.
|
||||||
|
- **`PE-FPGA-002`** flash di config se c’è un flash IC (altrimenti skip).
|
||||||
|
- **`PE-FPGA-003`** VCCINT/VCCIO solo se net nominati.
|
||||||
|
|
||||||
|
Test: `tests/test_hf_line_check.py` — `test_fpga_only_when_present_config_flash_optional`.
|
||||||
|
|
||||||
|
#### USB-PD contratto — `usb_pd_check.py` (2.62.1)
|
||||||
|
|
||||||
|
- **`PE-PD-001`** controller PD sul grafo (FUSB302/TPS257/…). Non è USB2 Rd. PDO non inventati.
|
||||||
|
|
||||||
|
Test: `tests/test_pcb_phase_b.py` — `test_usb_pd_skips_without_pd_controller`.
|
||||||
|
|
||||||
|
### 1.5 Review AI per-IC + post-pass
|
||||||
|
|
||||||
|
Moduli: `review_session.py`, `pcb_review.py` non entra qui. DeepSeek, PDF una volta per IC, fail-soft. Prompt tools grafo (`find_connected_components`, `get_net_for_pin`, `get_pintable`, `get_datasheet_excerpt`). Finding LLM = REVIEW, mai RULE ERROR (`finding_engine`).
|
||||||
|
|
||||||
|
Skip se: niente MPN; niente PDF (`not_reviewed`); fingerprint invariato (`review_fingerprint.py`).
|
||||||
|
|
||||||
|
- Review loop: `tests/test_native_review_loop.py` (5); `tests/test_validation_no_tool_recovery.py` `test_no_tool_calls_triggers_forced_submit_next_turn`; `tests/test_validation_concurrency.py` (3); `tests/test_validation_trace_log.py` (3); `tests/test_review_parse.py` (2); `tests/test_quote_verify.py` (5); `tests/test_finding_engine.py` (`test_llm_review_is_never_rule`, `test_review_and_risk_cannot_stay_error`, `test_insufficient_evidence_does_not_stay_error`); `tests/test_fase_b_checks.py` `test_llm_error_review_clamped_in_complete_finding`; `tests/test_review_fingerprint.py` (2); `tests/test_reprocess.py` (4); `tests/test_deepseek_provider.py` (`test_thinking_only_echoes_reasoning_content_on_follow_up` e roundtrip tool).
|
||||||
|
- Excerpt cross-IC: `tests/test_datasheet_excerpt_tool.py` (12); `tests/test_cache_breakpoint_limit.py` (3).
|
||||||
|
- **Normalize** (downgrade-only): `tests/test_normalize_findings.py` (14); `tests/test_periscope_postpass_rewrite.py` `test_emmaforo_uart_normalize_cannot_promote_warning`.
|
||||||
|
- **Dedupe** cross-IC: `tests/test_dedupe_findings.py` (11); `tests/test_periscope_postpass_rewrite.py` `test_emmaforo_uart_dedupe_collapses_both_endpoints`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Esame PCB (`MODE=pcb`)
|
||||||
|
|
||||||
|
Stadi: `ensure_graph` → `parse_pcb` → `classify` → `inventory` → **`run_pcb_checks`** → `ai_review` → `pcb_report.json`. ID finding `PCB-{ref}-{001}`. Mutex vs analisi schema e vs placement. Inventario net (`pcb_inventory.py`) è numeri, non finding.
|
||||||
|
|
||||||
|
KiCad DRC **non** è un nodo Periscope. `tests/test_pcb_software_bugs.py` `test_drc_unconnected_items_count` e `test_hubaudio_pcb_lay004_vs_drc_order_of_magnitude` servono solo a **non** copiare il DRC.
|
||||||
|
|
||||||
|
Suite orchestrazione: `tests/test_pcb_review.py` `test_run_pcb_checks_assigns_pcb_ids_and_recommendations`, `test_pcb_pipeline_imports_build_placement_plan`; `tests/test_pcb_phase_b.py` `test_run_pcb_checks_phase_b_absent_is_silent`, `test_phase_b_rule_catalog`; `tests/test_interface_class_check.py` `test_merge_drops_duplicate_pcb_interface_findings`; `tests/test_pcb_sse_terminal.py` (4).
|
||||||
|
|
||||||
|
### 2.1 Layout ingest (non DRC)
|
||||||
|
|
||||||
|
`parsers_kicad_pcb.py` → `LayoutGraph`. Nessun default 1 oz.
|
||||||
|
|
||||||
|
- Test: `tests/test_kicad_pcb.py` (14); `tests/test_pcb_via_not_pad.py` (geometria via≠pad); `tests/test_pcb_software_bugs.py` `test_kicad10_name_only_nets_fill_index`.
|
||||||
|
|
||||||
|
### 2.2 Net / footprint — `pcb_net_match.py` + `pcb_checks._incomplete_layout_finding`
|
||||||
|
|
||||||
|
#### `PE-LAY-001`
|
||||||
|
|
||||||
|
Pad PCB su net diverso dallo schema (stesso pin).
|
||||||
|
Skip se: layout assente.
|
||||||
|
|
||||||
|
- Test: `tests/test_pcb_review.py` — `test_pad_net_mismatch_is_pe_lay_001`, `test_hierarchy_plus_real_mismatch_keeps_only_real_pe_lay_001`.
|
||||||
|
|
||||||
|
#### `PE-LAY-002`
|
||||||
|
|
||||||
|
Ref in schema, nessun footprint (tranne #PWR). Distingue “manca sulla PCB”.
|
||||||
|
|
||||||
|
- Test: `tests/test_pcb_review.py` `test_missing_footprint_is_pe_lay_002`; `tests/test_incomplete_pcb.py` `test_unplaced_ref_is_lay_002_not_invented_xy`.
|
||||||
|
|
||||||
|
#### `PE-LAY-003`
|
||||||
|
|
||||||
|
Stesso net, solo prefisso `/sheet/` KiCad (INFO, una volta).
|
||||||
|
|
||||||
|
- Test: `tests/test_pcb_review.py` `test_kicad_hierarchy_slash_is_one_sync_finding_not_per_pad`.
|
||||||
|
|
||||||
|
#### `PE-LAY-004` — `pcb_checks.py` (**non in catalogo `finding_engine`**)
|
||||||
|
|
||||||
|
Pad net senza rame (track **o** via **o** zone). Skip NC `unconnected-(…)`. INFO INSUFFICIENT, non FAIL inventato.
|
||||||
|
|
||||||
|
- Test: `tests/test_incomplete_pcb.py` `test_run_pcb_checks_on_partial_layout_does_not_invent_tracks`; `tests/test_pcb_software_bugs.py` — `test_lay004_zone_counts_as_copper`, `test_lay004_skips_unconnected_nc_and_matches_sheet_prefix`, `test_hubaudio_pcb_lay004_vs_drc_order_of_magnitude`.
|
||||||
|
|
||||||
|
### 2.3 Placement vs `layout_rules` — `placement_check.py`
|
||||||
|
|
||||||
|
Senza mm in `layout_rules`: skip. Niente 3 mm default. Distanza euclidea pad-pad **non** usata.
|
||||||
|
|
||||||
|
#### `PE-PLC-001`
|
||||||
|
|
||||||
|
Path rame (segmenti) vs `max_distance_mm`.
|
||||||
|
|
||||||
|
- Test: `tests/test_placement_check.py` — `test_crystal_load_cap_beyond_max_distance_mm_is_ps_plc_001`, `test_crystal_load_cap_within_max_distance_mm_is_silent`, `test_track_path_longer_than_max_distance_mm_is_ps_plc_001`; `tests/test_pcb_review.py` `test_close_decoupling_has_no_plc_001`; `tests/test_placement_check.py` `test_simple_project_without_pcb_has_no_ps_plc`.
|
||||||
|
|
||||||
|
#### `PE-PLC-002`
|
||||||
|
|
||||||
|
Via in courtyard vs `min_via_count`. Skip senza vertici courtyard e senza min.
|
||||||
|
|
||||||
|
- Test: `tests/test_placement_check.py` `test_via_count_is_calculated_from_courtyard_and_min_parameter` (geometria `_in_poly`; non emette ancora il finding end-to-end).
|
||||||
|
|
||||||
|
#### `PE-PLC-003`
|
||||||
|
|
||||||
|
`same_layer` decoupling.
|
||||||
|
|
||||||
|
- Test: `tests/test_placement_check.py` — `test_same_layer_param_opposite_layers_is_ps_plc_003`, `test_same_layer_param_same_copper_is_silent`, `test_opposite_layers_without_same_layer_param_is_silent`, `test_opposite_layers_with_via_in_courtyard_is_silent`.
|
||||||
|
|
||||||
|
#### `PE-PLC-004`
|
||||||
|
|
||||||
|
Keepout: net estraneo nel courtyard. Non è DRC rame-pad.
|
||||||
|
|
||||||
|
- Test: `tests/test_placement_check.py` — `test_keepout_foreign_track_in_courtyard_is_ps_plc_004`, `test_ic_courtyard_pad_nets_are_not_keepout_violations`, `test_keepout_own_net_in_courtyard_is_silent`, `test_keepout_without_courtyard_is_silent`.
|
||||||
|
|
||||||
|
#### `PE-PLC-005` (**non in catalogo `finding_engine`**)
|
||||||
|
|
||||||
|
Proximity non misurabile perché manca la **traccia**.
|
||||||
|
|
||||||
|
- Test: `tests/test_placement_check.py` `test_unrouted_crystal_cap_is_insufficient_not_euclidean`.
|
||||||
|
|
||||||
|
Schema runner omette i check layout: `tests/test_placement_check.py` `test_schema_deterministic_runner_omits_layout_checks`.
|
||||||
|
|
||||||
|
### 2.4 SI — `si_check.py` + ImpedenceFinder
|
||||||
|
|
||||||
|
Gate: `layout_rules` kind + `net_class`/quote sul **bus citato**. Skip I2C/GPIO/EN/CC/strap RC. Bus: USB2, USB3, ETH MDI, RGMII, SGMII, DDR3 CLK/DQS/DQ/ADDR, HDMI, PCIe, LVDS; 2.62.0 aggiunge MIPI e clock HF (`bus_class`); XTAL/OSCIN fuori.
|
||||||
|
|
||||||
|
Z0: `impedance.py` / `impedance_traces.py`. CPWG → errore, non numero inventato. Senza stackup: skip misura.
|
||||||
|
|
||||||
|
Suite gate: `tests/test_si_check.py` `test_skip_i2c_gpio_cc_regn`, `test_i2c_not_checked_as_50_ohm`, `test_simple_project_without_pcb_has_no_ps_si_001`; `tests/test_hf_line_check.py` `test_bus_class_mipi_hf_clk_not_xtal`. Calcolatrice: `tests/test_impedance.py` (20); misura net: `tests/test_impedance_traces.py` (10).
|
||||||
|
|
||||||
|
#### `PE-SI-001` length_match / skew coppia
|
||||||
|
|
||||||
|
Skip senza mm datasheet.
|
||||||
|
|
||||||
|
- Test: `tests/test_si_check.py` `test_length_match_2_5mm_vs_1mm_is_fail`, `test_coverage_mdi_rgmii_ddr3_usb3_gated_not_cross_mapped`.
|
||||||
|
|
||||||
|
#### `PE-SI-002` Zavg/min/max vs finestra datasheet
|
||||||
|
|
||||||
|
Mai folklore 90 Ω.
|
||||||
|
|
||||||
|
- Test: `tests/test_si_check.py` `test_emmaforo_usb_avg_in_window_min_out_is_margin`, `test_coverage_mdi_rgmii_ddr3_usb3_gated_not_cross_mapped`.
|
||||||
|
|
||||||
|
#### `PE-SI-003` lunghezza max mm
|
||||||
|
|
||||||
|
- Test: `tests/test_si_check.py` `test_coverage_mdi_rgmii_ddr3_usb3_gated_not_cross_mapped`.
|
||||||
|
|
||||||
|
#### `PE-SI-004` spacing intra-coppia mm
|
||||||
|
|
||||||
|
Skip senza mm. Copertura indiretta nella stessa suite SI gated.
|
||||||
|
|
||||||
|
#### `PE-SI-005` piano di riferimento / layer
|
||||||
|
|
||||||
|
Skip senza FACT datasheet.
|
||||||
|
|
||||||
|
#### `PE-SI-006` conteggio via HS vs min/max datasheet
|
||||||
|
|
||||||
|
Skip senza min/max.
|
||||||
|
|
||||||
|
#### `PE-SI-008` return via accanto alla coppia (`return_path`)
|
||||||
|
|
||||||
|
Skip senza kind.
|
||||||
|
|
||||||
|
#### `PE-SI-009` serie R ohm sul net HS (non EN-RC)
|
||||||
|
|
||||||
|
- Test: `tests/test_si_check.py` `test_en_rc_series_resistor_does_not_paint_usb`.
|
||||||
|
|
||||||
|
#### `PE-SI-010` bus HS misurato, libreria senza FACT Z → INFO, non FAIL 90 Ω
|
||||||
|
|
||||||
|
- Test: `tests/test_si_check.py` `test_usb_without_library_z_is_insufficient_not_90ohm_fail`, `test_empty_net_class_impedance_does_not_paint_usb`; `tests/test_hf_line_check.py` `test_existing_si_usb_not_polluted_by_hf_geometry`.
|
||||||
|
|
||||||
|
Refresh extract SI vecchio: `tests/test_pcb_plan_closeout.py` `test_si_refresh_when_old_extract_has_only_decoupling`, `test_si_extract_needed_skips_board_ics_only_not_shared_library`.
|
||||||
|
|
||||||
|
### 2.5 Linee HF extra — `hf_line_check.py` (2.62.0 Fase A)
|
||||||
|
|
||||||
|
Track ≠ via ≠ pad ≠ zona. Stub = traccia dangling.
|
||||||
|
|
||||||
|
#### `PE-SI-007` stub vs mm datasheet
|
||||||
|
|
||||||
|
INFO se misura c’è e mm mancano (INSUFFICIENT). Skip: pad-pad, via, zone.
|
||||||
|
|
||||||
|
- Test: `tests/test_hf_line_check.py` — `test_no_layout_skips_hf_geometry`, `test_pad_to_pad_usb_has_no_stub`, `test_via_is_not_a_stub`, `test_zone_is_not_a_track_stub`, `test_dangling_track_stub_vs_datasheet_mm_is_fail`, `test_measured_stub_without_datasheet_mm_is_insufficient`.
|
||||||
|
|
||||||
|
#### `PE-SI-011` gap pour GND sotto coppia HF (REVIEW)
|
||||||
|
|
||||||
|
Skip senza stackup/zone.
|
||||||
|
|
||||||
|
- Test: `tests/test_hf_line_check.py` `test_split_under_pair_needs_stackup_and_zone`.
|
||||||
|
|
||||||
|
#### `PE-SI-012` terminazione BOM serie/parallelo FACT
|
||||||
|
|
||||||
|
Skip se manca il terminatore (non si inventa 50 Ω).
|
||||||
|
|
||||||
|
- Test: `tests/test_hf_line_check.py` `test_bom_termination_is_fact_missing_is_skip`.
|
||||||
|
|
||||||
|
Catalogo HF: `tests/test_hf_line_check.py` `test_rule_catalog_hf_ids`. DDR/CPU/FPGA: §1.4 (stesso `check_memory_fpga_classes` in `run_pcb_checks`).
|
||||||
|
|
||||||
|
### 2.6 Potenza / via / termico sul board — `pcb_power_thermal.py`
|
||||||
|
|
||||||
|
#### `PE-PWR-001` (PCB) — `check_pcb_power_traces`
|
||||||
|
|
||||||
|
Larghezza **traccia** × spessore rame vs I_load/Imax/I_abs. IPC-2221 solo con thickness **e** ΔT da Tjmax. Iout_max non è carico. Niente 500 mA USB.
|
||||||
|
|
||||||
|
Skip se: manca I datasheet (**skip vero**, non INFO INSUFFICIENT — 2.61.1).
|
||||||
|
|
||||||
|
- Test: `tests/test_pcb_review.py` — `test_power_trace_ipc2221_errors_when_load_exceeds_ampacity`, `test_power_trace_uses_parsed_width_and_thickness`, `test_power_trace_skips_without_i_load`, `test_power_trace_skips_iout_max_as_load`, `test_power_trace_uses_imax_when_i_load_absent`, `test_power_trace_uses_i_abs_when_i_load_absent`; `tests/test_interface_class_check.py` `test_usbc_vbus_does_not_invent_usb_500ma`.
|
||||||
|
|
||||||
|
#### `PE-VIA-001` — `check_pcb_via_current`
|
||||||
|
|
||||||
|
Se I c’è e sul net **zero via**: INFO conteggio. Se le via ci sono: silenzio — **nessuna tabella ampacity**. Via ≠ traccia.
|
||||||
|
|
||||||
|
Skip se: manca I.
|
||||||
|
|
||||||
|
- Test: `tests/test_pcb_software_bugs.py` `test_via_current_skips_without_i_load`.
|
||||||
|
|
||||||
|
#### `PE-VIA-002` (2.62.1)
|
||||||
|
|
||||||
|
I + conteggio/drill FACT. Tabella IPC via **non** applicata.
|
||||||
|
|
||||||
|
Skip se: manca I.
|
||||||
|
|
||||||
|
- Test: `tests/test_pcb_phase_b.py` `test_via_current_skips_without_i_does_not_invent_ipc`.
|
||||||
|
|
||||||
|
#### `PE-THM-001` — `check_pcb_thermal_copper`
|
||||||
|
|
||||||
|
P = I_load × drop; courtyard senza pour/via.
|
||||||
|
|
||||||
|
Skip se: P sconosciuta.
|
||||||
|
|
||||||
|
- Test: `tests/test_pcb_review.py` `test_thermal_copper_warns_without_pour_or_vias`.
|
||||||
|
|
||||||
|
#### `PE-THM-002` — `check_pcb_junction_temp`
|
||||||
|
|
||||||
|
Tj = Ta + P·θJA solo con P, θJA, area rame, conteggio via. Non è FEM.
|
||||||
|
|
||||||
|
Skip/INSUFFICIENT se manca un fattore.
|
||||||
|
|
||||||
|
- Test: `tests/test_pcb_plan_closeout.py` — `test_tj_when_theta_copper_vias_exist`, `test_tj_insufficient_without_theta`.
|
||||||
|
|
||||||
|
#### `PE-KEL-001`
|
||||||
|
|
||||||
|
Pin Kelvin/sense sullo stesso net di carico. Ignora CRS/IN±.
|
||||||
|
|
||||||
|
- Test: `tests/test_pcb_review.py` `test_kelvin_sense_pin_on_shared_net`; `tests/test_pcb_software_bugs.py` `test_kelvin_ignores_crs_and_in_plus`.
|
||||||
|
|
||||||
|
#### `PE-STCH-001`
|
||||||
|
|
||||||
|
Segnale sopra pour GND senza via GND nel bbox. Skip GPIO minuscoli.
|
||||||
|
|
||||||
|
- Test: `tests/test_pcb_review.py` — `test_gnd_stitch_skips_tiny_gpio`, `test_gnd_stitch_info_when_signal_has_pour_but_no_via`.
|
||||||
|
|
||||||
|
### 2.7 Derating / gerarchia / timing / PI
|
||||||
|
|
||||||
|
#### `PE-DRT-001` / `PE-DRT-002` / `PE-DRT-003` — `pcb_checks.check_pcb_derating` + `derating.py`
|
||||||
|
|
||||||
|
Vop vs Vrated: ERROR / MARGIN / PASS INFO. Niente % dielettrico inventato. Skip se manca Vop o Vr.
|
||||||
|
|
||||||
|
- Test: `tests/test_fase_b_checks.py` `test_derating_pass_margin_risk`; `tests/test_derating.py` (7, C_eff stima — non è un PE-* a parte).
|
||||||
|
|
||||||
|
#### `PE-HIER-001` — `hierarchy.py`
|
||||||
|
|
||||||
|
Pin IC senza net nominato.
|
||||||
|
|
||||||
|
- Test: `tests/test_fase_b_checks.py` `test_hierarchy_component_pin_net_block`.
|
||||||
|
|
||||||
|
#### `PE-TIM-001` / `PE-TIM-002` — `timing_check.py`
|
||||||
|
|
||||||
|
001 RC reset vs t_reset; 002 strap vs Vih/Vil.
|
||||||
|
Skip senza R/C e t_reset o Vih/Vil.
|
||||||
|
|
||||||
|
- Test: `tests/test_fase_b_checks.py` — `test_timing_skips_without_numbers`, `test_timing_reset_rc_vs_t_reset`. **`PE-TIM-002` non ha un test che fa scattare il finding** (solo skip condiviso).
|
||||||
|
|
||||||
|
#### `PE-PI-001` / `PE-PI-002` — `pi_check.py`
|
||||||
|
|
||||||
|
001 cap locale sul rail IC; 002 locale vs bulk se i valori C esistono. Distanza mm solo da `layout_rules` (altrimenti è PE-PLC-001). Skip net NC.
|
||||||
|
|
||||||
|
- Test: `tests/test_fase_b_checks.py` — `test_pi_missing_local_is_risk_not_error`, `test_pi_any_cap_on_slash_prefixed_rail_suppresses_missing_local`, `test_pi_bulk_only_does_not_claim_no_local`; `tests/test_pcb_software_bugs.py` `test_emi_and_pi_skip_unconnected_nets`. **`PE-PI-002` non ha un assert dedicato sul `rule_id`.**
|
||||||
|
|
||||||
|
### 2.8 ESD / return / EMI / SPOF
|
||||||
|
|
||||||
|
#### `PE-ESD-001` — `esd_return_check.check_esd`
|
||||||
|
|
||||||
|
Net J* ∩ IC senza parte ESD: REVIEW, non ERROR. Niente GPIO/NC/rail spam.
|
||||||
|
|
||||||
|
- Test: `tests/test_fase_b_checks.py` — `test_esd_without_part_is_review_not_error`, `test_esd_skips_gpio_nc_unconnected_and_onboard_power`, `test_esd_one_finding_per_connector_ic_path`, `test_esd_skips_when_protection_part_on_net`.
|
||||||
|
|
||||||
|
#### `PE-ESD-002` (2.62.1) — `check_esd_distance`
|
||||||
|
|
||||||
|
Distanza **pad** TVS–connettore (via ≠ pad). INFO se no mm.
|
||||||
|
|
||||||
|
- Test: `tests/test_pcb_phase_b.py` `test_esd_distance_uses_pads_not_vias`.
|
||||||
|
|
||||||
|
#### `PE-RET-001` — `check_return_path`
|
||||||
|
|
||||||
|
Net HS sopra pour GND senza via GND nel bbox: REVIEW.
|
||||||
|
|
||||||
|
- Test: `tests/test_fase_b_checks.py` `test_return_path_hs_is_review`.
|
||||||
|
|
||||||
|
#### `PE-EMI-001` — `emi_check.py`
|
||||||
|
|
||||||
|
Solo se `layout_rules` cita choke/ferrite/shield. Non IEC 61000. Skip NC.
|
||||||
|
|
||||||
|
- Test: `tests/test_pcb_plan_closeout.py` `test_emi_only_with_datasheet_fact`; `tests/test_pcb_software_bugs.py` `test_emi_and_pi_skip_unconnected_nets`.
|
||||||
|
|
||||||
|
#### `PE-SPOF-001` — `spof_check.py`
|
||||||
|
|
||||||
|
Un regolatore o XTAL condiviso: REVIEW, mai ERROR.
|
||||||
|
|
||||||
|
- Test: `tests/test_pcb_plan_closeout.py` `test_spof_review_single_ldo`.
|
||||||
|
|
||||||
|
### 2.9 BOM ↔ PCB ↔ datasheet — `bom_pcb_check.py`
|
||||||
|
|
||||||
|
Skip se manca un lato (BOM, footprint, o numero datasheet).
|
||||||
|
|
||||||
|
#### `PE-BOM-010` famiglia package
|
||||||
|
|
||||||
|
- Test: `tests/test_pcb_plan_closeout.py` `test_package_family_mismatch_is_pe_bom_010`.
|
||||||
|
|
||||||
|
#### `PE-BOM-011` pin_count/pintable vs pad PCB
|
||||||
|
|
||||||
|
EP/thermal extra e via **non** sono pad. Via ≠ pad.
|
||||||
|
|
||||||
|
- Test: `tests/test_pcb_plan_closeout.py` `test_package_family_mismatch_is_pe_bom_010` (stesso test asserisce anche 011); `tests/test_pcb_software_bugs.py` `test_ep_split_pads_not_bom_011`; `tests/test_pcb_via_not_pad.py` (13 test geometria + `test_pcb_pipeline_reparses_stale_layout_graph`); `tests/test_incomplete_pcb.py` `test_partial_board_via_is_not_a_pad`.
|
||||||
|
|
||||||
|
#### `PE-BOM-012` Vop vs rating/abs-max legato al **pin**
|
||||||
|
|
||||||
|
- Test: `tests/test_pcb_plan_closeout.py` `test_abs_max_voltage_and_temp_and_current`; `tests/test_pcb_software_bugs.py` `test_vddcr_absmax_does_not_bind_3v3_io`.
|
||||||
|
|
||||||
|
#### `PE-BOM-013` Top vs abs-max T
|
||||||
|
|
||||||
|
- Test: `tests/test_pcb_plan_closeout.py` `test_abs_max_voltage_and_temp_and_current`.
|
||||||
|
|
||||||
|
#### `PE-BOM-014` I_load vs rating
|
||||||
|
|
||||||
|
Skip se manca I.
|
||||||
|
|
||||||
|
- Test: `tests/test_pcb_plan_closeout.py` — `test_abs_max_voltage_and_temp_and_current`, `test_inductor_current_rating`.
|
||||||
|
|
||||||
|
### 2.10 Resto Fase B (2.62.1)
|
||||||
|
|
||||||
|
#### `PE-STK-001` — `stackup_check.py`
|
||||||
|
|
||||||
|
Spessore rame board vs stackup fab sourced. Mai 1 oz default.
|
||||||
|
Skip se: niente stackup layout o niente FACT fab.
|
||||||
|
|
||||||
|
- Test: `tests/test_pcb_phase_b.py` — `test_stackup_skips_without_fab_spec`, `test_stackup_vs_fab_spec_is_review_not_invented_oz`.
|
||||||
|
|
||||||
|
#### `PE-ANT-001` / `PE-ANT-002` — `antenna_layout_check.py`
|
||||||
|
|
||||||
|
Keepout zona e matching L/C sul feed. Solo se antenna/net RF sul grafo. Niente 50 Ω inventati.
|
||||||
|
Skip se: antenna assente; matching manca e non c’è FACT (002 non emette).
|
||||||
|
|
||||||
|
- Test: `tests/test_pcb_phase_b.py` — `test_antenna_skips_when_absent`, `test_antenna_keepout_and_match_are_facts`.
|
||||||
|
|
||||||
|
#### `PE-PDN-001` — `pdn_check.py`
|
||||||
|
|
||||||
|
Z(f) solo con FACT in frequenza. Z0 SI **non** è PDN.
|
||||||
|
|
||||||
|
- Test: `tests/test_pcb_phase_b.py` `test_pdn_skips_without_zf`.
|
||||||
|
|
||||||
|
Silenzio Fase B se assente: `tests/test_pcb_phase_b.py` `test_run_pcb_checks_phase_b_absent_is_silent`. Catalogo: `test_phase_b_rule_catalog`.
|
||||||
|
|
||||||
|
### 2.11 Review AI PCB
|
||||||
|
|
||||||
|
Modulo: `pcb_review.py` + `services/pcb_validation.py`. Stesso oggetto finding. Spiega FACT deterministici; **non** rilegge il PDF; non inventa interfacce / PoE / SuperSpeed / mm / Z / I.
|
||||||
|
|
||||||
|
Skip se: niente JSON libreria per l’IC.
|
||||||
|
|
||||||
|
- Test: `tests/test_pcb_review.py` — `test_pcb_ai_finding_gets_action`, `test_parse_review_action_field_without_recommendation`, `test_layout_context_includes_via_counts`, `test_layout_context_includes_domain_and_group`, `test_pcb_ai_skips_without_library_extraction`, `test_library_constraints_fill_from_storage`, `test_inventory_lists_net_length_and_pair`; `tests/test_finding_engine.py` `test_pcb_review_ai_finding_action_without_recommendation`; `tests/test_pcb_software_bugs.py` `test_thinking_reasoning_content_echo_still_present`.
|
||||||
|
|
||||||
|
### 2.12 Tab antenna RF (non in `run_pcb_checks`)
|
||||||
|
|
||||||
|
Moduli: `antenna_rf.py`, `antenna_geometry.py`. Topologia matching + ricetta routing. No EM/VSWR/CPWG. Distinto da `PE-ANT-001/002`.
|
||||||
|
|
||||||
|
- Test: `tests/test_antenna_rf.py` — `test_verify_finds_matching_path_to_ant_footprint`, `test_verify_missing_matching_is_warning`, `test_design_recipe_needs_marker_without_ant`, `test_design_recipe_ready_with_ant_and_stackup`, `test_geometry_templates_produce_export`, `test_geometry_overflow_tiny_zone`, `test_design_recipe_meander_template`.
|
||||||
|
|
||||||
|
Placement/auto-place **non** è esame PCB: `tests/test_placement_pack.py`, `tests/test_placement_pipeline.py` (fuori da questo albero di analisi).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Motore finding (condiviso)
|
||||||
|
|
||||||
|
`finding_engine.py`: FACT / REQUIREMENT / INFERENCE; ERROR solo RULE+MANDATORY; `decisions.json` non ri-naga.
|
||||||
|
|
||||||
|
- Test: `tests/test_finding_engine.py` (11); `tests/test_finding_schema.py` (7); `tests/test_fase_b_checks.py` `test_sort_findings_error_then_class`; `tests/test_review_workflow.py` (10 — stati finding UI, non check PE). CAD bridge: `tests/test_cad_bridge.py` (5).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test che esistono ma non sono analisi
|
||||||
|
|
||||||
|
`tests/` ha **114 file / ~730 test**. Oltre ai nodi sopra restano overlay nativi, upload, auth, billing seam, UI rewrite. Non sono check PE-*.
|
||||||
|
|
||||||
|
**Upload / ingest cartella** (non analisi): `tests/test_periscope_frontend_upload_rewrite.py` `test_upload_zone_is_src`; `tests/test_kicad_project.py` (picker cartella, `.history`, Safari stem); `tests/test_netlist_bundle.py` (zip, zip-slip); `tests/test_kicad_pcb.py` `test_upload_pcb_sets_has_pcb`, `test_upload_sch_as_pcb_is_rejected`; `tests/test_purple_parts_resolver.py` (upload BOM/LCSC); `tests/test_reopen_replace.py` `test_reopen_then_replace_bom_and_netlist`.
|
||||||
|
|
||||||
|
**Auth**: i test auth esistono (`tests/test_local_auth.py`, `tests/test_periscope_auth_rewrite.py`, `tests/test_pinscope_compat.py`, più overlay che tengono il contratto login). Non si descrivono segreti, JWT, password, o flussi di autenticazione.
|
||||||
|
|
||||||
|
Altri non-analisi: rewrite frontend/Docker (`test_periscope_*_rewrite.py`, `test_periscope_leftover_package_requirements.py`, `test_periscope_boot_without_dependency_product.py`); delete progetto; cost estimator; event bridge; job runner; `test_deepseek_only.py` (routing modello, non finding).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conteggio nodi
|
||||||
|
|
||||||
|
| Gruppo | Nodi |
|
||||||
|
| --- | --- |
|
||||||
|
| Check solo schema (27 PE-* + LED senza ID) | 28 |
|
||||||
|
| USB-C / ETH / PoE (schema e PCB) | 13 |
|
||||||
|
| `PE-PWR-001` (due source: schema e PCB) | 1 |
|
||||||
|
| DDR / CPU / FPGA / PD (grafo, invocati da `run_pcb_checks`) | 11 |
|
||||||
|
| Check solo PCB (layout, SI/HF, potenza, Fase B, …) | 47 |
|
||||||
|
| Processo: 3 skill + DigiKey + value + IC AI + PCB AI + tab RF | 8 |
|
||||||
|
| **Totale nodi analisi** | **108** |
|
||||||
|
|
||||||
|
Check PE-* in codice: **99** (tutti elencati). Più LED senza `rule_id`. `PE-LAY-004`, `PE-PLC-005`, `PE-TH-002` sono in codice; `PE-LAY-004` / `PE-PLC-005` non sono in `_seed()` di `finding_engine.py`.
|
||||||
|
|
||||||
|
Buchi di test noti (il check c’è, il fire test no): `PE-TIM-002`, `PE-PI-002` (nessun assert `rule_id`), `PE-PLC-002` (solo geometria `_in_poly`), `PE-SI-004` / `PE-SI-005` / `PE-SI-006` / `PE-SI-008` (gate SI, niente caso dedicato).
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# Conformità coding — Periscope intero
|
||||||
|
|
||||||
|
Costituzione: `docs/development/CODING_CONSTITUTION.md` (*code that fits in your head*). **Per sezione**, non per funzione: conforme sì / no e perché. Taglio 2.62.1 + questa macro-fase (libreria come porta, test riorganizzati, AF+AI extra). Auth/JWT/users: **non toccati**; la sezione esiste e viene giudicata, non modificata.
|
||||||
|
|
||||||
|
Giudizio: **conforme** = un ingegnere legge la sezione senza ricostruire un sistema nascosto. **Non conforme** = troppe responsabilità, stato implicito, eccezioni ingoiate, o semantica a rischio. Un file lungo può essere conforme se il flusso è ovvio; uno corto no se fa tre mestieri.
|
||||||
|
|
||||||
|
Zero eccezioni al principio. Le sezioni “non conformi” restano debito: non si “passa” la costituzione dichiarandole OK.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Albero nativo / dependency
|
||||||
|
|
||||||
|
**Non conforme.** `periscope/src` (nativo) e `periscope/dependency` (PinScope ereditato) si fondono con `sys.path` + `pkgutil.extend_path`. Chi vince dipende dall’ordine di insert (`backend/__init__.py` vs `tests/conftest.py`). Due `projects.py`, due `pipeline.py`, due `extraction.py`. Per capire un import serve la mappa dei shadow, non il modulo. È un adattatore temporaneo con path di rimozione (Fase C) ma **oggi** non sta in testa.
|
||||||
|
|
||||||
|
## 2. Glue di root (Docker, compose, `backend/__init__.py`, script)
|
||||||
|
|
||||||
|
**Conforme a metà.** `backend/__init__.py` è corto e dice il mestiere. `scripts/update-periscope.sh` è una procedura esplicita (`/root/periscope`, niente `data/`). `docker-compose.yml` è piccolo. Il debito è il merge dei due tree a boot, non gli script.
|
||||||
|
|
||||||
|
## 3. Motore finding (`periscopex/finding_engine.py`, schema)
|
||||||
|
|
||||||
|
**Conforme.** Un oggetto finding, FACT / REQUIREMENT / INFERENCE, clamp verso il basso, `INSUFFICIENT`. Flusso visibile. Test stretti su tipo, codice, severity. ~450 righe: lungo ma un mestiere.
|
||||||
|
|
||||||
|
## 4. Modello semantico (`models.py`)
|
||||||
|
|
||||||
|
**Conforme.** Pad, via, track, zone, pin, net sono tipi distinti. Pydantic, niente god-class UI. ~534 righe di dati, non di orchestrazione.
|
||||||
|
|
||||||
|
## 5. Parser schematico (PADS, EDIF, KiCad sch)
|
||||||
|
|
||||||
|
**Conforme a metà.** Funzioni pure, fixture `simple_project`. `parsers_kicad.py` (~760) e `parsers_edif.py` (~465) sono grandi; il mestiere resta “testo → grafo”. Non classificano via PCB come pad (coperto da test rewrite). Debito: taglia dei file, non la semantica.
|
||||||
|
|
||||||
|
## 6. Parser PCB KiCad (`parsers_kicad_pcb.py`)
|
||||||
|
|
||||||
|
**Conforme.** Via ≠ pad è esplicito e testato. Un input `.kicad_pcb` → `LayoutGraph`. Non è DRC. ~446 righe.
|
||||||
|
|
||||||
|
## 7. Grafo (`graph.py`, `netlist_bundle.py`)
|
||||||
|
|
||||||
|
**Conforme.** Costruzione da BOM+netlist, helper di attraversamento. Ferrite Z è un innesto a parte (`ferrite_z.py`), non un if U1.
|
||||||
|
|
||||||
|
## 8. Check deterministici PCB (`pcb_checks.py` + moduli `PE-*`)
|
||||||
|
|
||||||
|
**Conforme nel disegno, non nel dispatcher.** Ogni modulo (`si_check`, `hf_line_check`, `stackup_check`, …) ha un mestiere e skip senza evidenza. `run_pcb_checks` è un elenco esplicito — si legge. **Non conforme:** `except Exception: log + skip` per ogni check (fallimento strumento vs finding vs dati, collassati). `pcb_power_thermal.py` (~748) e `si_check.py` (~862) e `interface_class_check.py` (~831) sono al limite: ancora un dominio, ma non “piccoli moduli”.
|
||||||
|
|
||||||
|
Non è DRC (clearance/track_width/annular restano KiCad). FEM assente: **conforme al vincolo**.
|
||||||
|
|
||||||
|
## 9. Check schematico (derating, LED, crystal, mux, rail, …)
|
||||||
|
|
||||||
|
**Conforme.** Un file ≈ un check, grafo in → finding out, niente mm inventati. Alcuni file >400 righe (`passive_rail_check`, `filter_check`) ma il flusso è lo stesso.
|
||||||
|
|
||||||
|
## 10. Review AI (validate, pcb_review, review_tools)
|
||||||
|
|
||||||
|
**Non conforme come sezione unica.** L’autorità deterministica è nei check; l’LLM spiega — questo è il disegno giusto (costituzione §8). In pratica `review_tools.py` (~940) e `validation.py` (dependency + native) sono loop di tool + state. `pcb_review.py` (~327) è il prompt + vicinato: quello sta in testa. Il loop live dipende ancora da pezzi ereditati. AF+AI (ipotesi HF → indagine deterministica) è un modulo extra, non sostituisce `run_pcb_checks`.
|
||||||
|
|
||||||
|
## 11. Estrazione datasheet (skills, `datasheet_extract.py`, `extraction.py`)
|
||||||
|
|
||||||
|
**Non conforme.** `datasheet_extract.py` ~1322 righe e `extraction.py` ereditato ~1298: prompt, tool, coerce, taxonomy, auto-resolve nello stesso file. Il mestiere “PDF → JSON libreria” è uno, l’implementazione no. Native vs inherited duplicato finché il pipeline non switcha. **Conforme nel ruolo:** LLM estrae, non è il verificatore.
|
||||||
|
|
||||||
|
## 12. Libreria componenti (store + porta HTTP/UI)
|
||||||
|
|
||||||
|
**Prima: non conforme come porta.** Catalogo e `library_has_*` vivevano in `services/projects.py` (CRUD progetti + libreria). La pagina `/library` era sola lettura e l’empty state mandava a “crea un progetto e lancia la review”. Admin `/admin/components` mescola libreria e users.
|
||||||
|
|
||||||
|
**Dopo questa macro-fase: conforme come porta, con debito.** `services/library.py` + `routers/library.py` + `/library` (import PDF senza esame, GET/PUT scheda). Store content-addressed (`datasheet_store.py`) resta esplicito. `library_gate.py` è piccolo e duro (niente pintable velenosa). Debito: `projects.py` re-export per non riscrivere la pipeline; `list_library_catalog` prima ingoiava JSON rotti — ora logga e salta la riga (dato rotto ≠ crash dello store).
|
||||||
|
|
||||||
|
## 13. Pipeline orchestrazione (`pipeline.py`, `pcb_pipeline.py`, job)
|
||||||
|
|
||||||
|
**Non conforme.** `pipeline.py` ~1522 righe: stage, storage, LLM, copy-in-library, graph. `pcb_pipeline.py` è più lineare (ensure_graph → parse → checks → AI → report) e si segue. Job/SSE sono un secondo asse di stato. Flusso reale: sì, ma non locale.
|
||||||
|
|
||||||
|
## 14. Storage (`storage.py`, GCS)
|
||||||
|
|
||||||
|
**Conforme.** Backend con chiavi stringa, locale vs GCS. Prefissi `users/…/projects/` e `library/` visibili.
|
||||||
|
|
||||||
|
## 15. HTTP — progetti, pipeline, report, impedance
|
||||||
|
|
||||||
|
**Non conforme per `routers/projects.py` (~1004)** e `pipeline.py` router (~783): troppi mestieri (CRUD, upload KiCad, DigiKey, LCSC). Report e impedance sono sezioni più piccole. La nuova `routers/library.py` è la porta libreria (un mestiere).
|
||||||
|
|
||||||
|
## 16. Auth / JWT / users
|
||||||
|
|
||||||
|
**Non toccata in questa macro-fase (vincolo).** La sezione **non è conforme** alla costituzione (Clerk + JWT locale + `admin.py` users nello stesso router, middleware che decide tre modi). Non si “sistema” qui. Non si aggiunge auth alla libreria.
|
||||||
|
|
||||||
|
## 17. Frontend — shell e pagina libreria
|
||||||
|
|
||||||
|
**Conforme a metà.** Overlay `src` su `dependency` (materialize) è un secondo albero da tenere in testa. Pagine progetto/report/dashboard sono lunghe ma a mestiere. `/library` era browse-only; ora è porta (import + modifica scheda) con form/editor spezzati. `admin/page.tsx` ~1780: **non conforme** (libreria + users + usage). Non toccato (users).
|
||||||
|
|
||||||
|
## 18. Frontend `api.ts` / types
|
||||||
|
|
||||||
|
**Non conforme.** `api.ts` nativo ~1179 righe, client unico per tutto. Types allineati ai modelli: sì. Un file non sta in testa. Nuove funzioni libreria restano lì per non inventare un secondo client.
|
||||||
|
|
||||||
|
## 19. Skills e taxonomy
|
||||||
|
|
||||||
|
**Conforme.** `skills/*/SKILL.md` + `validate.py` in-process; taxonomy JSON per tipo. `repo_paths` al posto di `Path(__file__)` magici (test rewrite). Non si chiama `upload_skills.py`.
|
||||||
|
|
||||||
|
## 20. Vendor ImpedenceFinder
|
||||||
|
|
||||||
|
**Conforme come confine, non come licenza.** Core Z0 chiuso, non FEM, non secondo set di formule (test). LICENSE UNKNOWN resta debito legale, non di forma del codice.
|
||||||
|
|
||||||
|
## 21. Test
|
||||||
|
|
||||||
|
**Prima: non conforme come mappa.** Un flat `tests/test_*.py` mescolava datasheet, libreria, schema, PCB, rewrite, auth. Copertura vera, ordine mentale no.
|
||||||
|
|
||||||
|
**Dopo: conforme come mappa, copertura tenuta.**
|
||||||
|
|
||||||
|
| Cartella | Mestiere |
|
||||||
|
| --- | --- |
|
||||||
|
| `tests/datasheet/` | preliminare PDF / pin / abs-max / I / Z / ferrite / DigiKey |
|
||||||
|
| `tests/library/` | porta libreria (anche senza esame) |
|
||||||
|
| `tests/schematic/` | net, BOM, review IC, check su grafo |
|
||||||
|
| `tests/pcb/` | `run_pcb_checks`, geometria, SI/HF già in 2.62.1 |
|
||||||
|
| `tests/impedancefinder/` | vendor Z0 (stesso nome package del vendor — non va sotto `pcb/`) |
|
||||||
|
| `tests/af_ai/` | extra: ipotesi HF → indagine deterministica (non DRC, no Z inventata) |
|
||||||
|
| `tests/` root | identity/rewrite/auth/job — non analisi |
|
||||||
|
|
||||||
|
`conftest.py` e `paths.py` restano in root. Test stretti (codice finding, via≠pad) restano la regola; i rewrite “il file sta in src” sono deboli ma sono recinti di albero, non di fisica.
|
||||||
|
|
||||||
|
## 22. Costituzioni Cursor (`.cursor/rules`, questo doc)
|
||||||
|
|
||||||
|
**Conforme.** Un master Markdown, `.mdc` operativi, niente Rust speculativo. Questo file è il giudizio, non una seconda costituzione.
|
||||||
|
|
||||||
|
## 23. Git / deploy
|
||||||
|
|
||||||
|
**Conforme al testo, fragile in pratica.** Commit+push a fine fase; deploy a fine macro-fase; no force. Il worktree locale era un pointer a `~/Development/pinscope` sparito: history recuperata da `github` `cursor/pcb-hf-analisi-675d` @ `20733f0` (2.62.1). Non si tocca `~/Development/pinscope` per edit di prodotto.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sintesi
|
||||||
|
|
||||||
|
| Sezione | Conforme |
|
||||||
|
| --- | --- |
|
||||||
|
| Finding engine + modelli + parser PCB | sì |
|
||||||
|
| Check PE-* come moduli | sì (dispatcher fail-soft no) |
|
||||||
|
| Skills/taxonomy/storage/Z0 vendor | sì |
|
||||||
|
| Dual tree src/dependency | no |
|
||||||
|
| Pipeline + extract ~1.3k + projects router | no |
|
||||||
|
| Auth/admin users | no (non toccare) |
|
||||||
|
| Libreria come porta | sì dopo questa fase (re-export debito) |
|
||||||
|
| Test a cartelle di mestiere | sì dopo questa fase |
|
||||||
|
|
||||||
|
Priorità debito (non questa fase): spezzare `pipeline.py` / `datasheet_extract.py`; togliere lo shadow dependency quando un modulo nativo è provato; non ingoiare Exception nei check PCB (distinguere tool failure).
|
||||||
|
|
||||||
|
**Niente Rust in questa macro-fase** — vedi `docs/rust-criteri.md`. FEM fuori. DRC = KiCad.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Libreria componenti — porta prodotto
|
||||||
|
|
||||||
|
La libreria **non** vive dentro l’esame. È una porta a sé: pagina `/library` + API `/api/library`. L’analisi (preliminare datasheet, schematico, PCB) *usa* la libreria; non è l’unico modo per riempirla.
|
||||||
|
|
||||||
|
## Flusso
|
||||||
|
|
||||||
|
```text
|
||||||
|
PDF + MPN
|
||||||
|
↓
|
||||||
|
POST /api/library/datasheets
|
||||||
|
↓
|
||||||
|
blob MD5 + ref MPN
|
||||||
|
↓
|
||||||
|
catalogo (tab Datasheets)
|
||||||
|
```
|
||||||
|
|
||||||
|
Nessun progetto, nessun `MODE=run` / `MODE=pcb`. Stesso store `library/datasheets/{blobs,refs}`.
|
||||||
|
|
||||||
|
Aggiornare una scheda già estratta:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/library/components/{ic|passive|passive_part|simple}/{mpn}
|
||||||
|
PUT stesso path (JSON)
|
||||||
|
```
|
||||||
|
|
||||||
|
Pintable IC: `library_gate` rifiuta pintable vuota o senza nomi (niente stub in libreria condivisa).
|
||||||
|
|
||||||
|
## Cosa non è
|
||||||
|
|
||||||
|
- Non è admin/Clerk/JWT (non si tocca auth).
|
||||||
|
- Non è DRC, non è FEM.
|
||||||
|
- Non estrae da sola il pintable (LLM = preliminare datasheet nell’analisi, o un job futuro). Il PDF c’è comunque.
|
||||||
|
- Non sostituisce `library_has_*` usati dalla pipeline.
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
`tests/library/` — catalogo, alias MPN, import PDF senza progetto, GET/PUT scheda, rifiuto PDF non valido.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Criteri Rust — Periscope
|
||||||
|
|
||||||
|
Costituzione: Python è il default. Rust **non** è un default. Questo documento dice *quando* un pezzo può entrare in Rust. Non sceglie ancora un pezzo. **Niente `rustup`, crate, né FFI finché una riga sotto non è scelta *e* misurata.**
|
||||||
|
|
||||||
|
FEM / OpenEMS / field solver: fuori dal prodotto. Non sono candidati Rust.
|
||||||
|
|
||||||
|
DRC: KiCad. Non è un candidato Rust in Periscope.
|
||||||
|
|
||||||
|
## Non solo velocità
|
||||||
|
|
||||||
|
Un bottleneck di CPU non basta. Un pezzo va in Rust solo se **tutti** i punti seguenti sono veri.
|
||||||
|
|
||||||
|
1. **Correttezza geometrica** — pad ≠ via ≠ traccia ≠ zona deve sopravvivere al confine. Se Rust collassa oggetti per “è più facile in un AABB”, il pezzo è rifiutato anche se è più veloce.
|
||||||
|
2. **Memoria / ownership** — pressione reale (PCB grandi, segmenti, zone) o lifetime che Python non può possedere senza copie cieche. Non “magari un giorno”.
|
||||||
|
3. **Parser o hot path** — il costo sta in un ciclo stretto (parse `.kicad_pcb`, walk segmenti, predicate geometriche) dopo profiling. Non nell’orchestrazione, non nell’I/O HTTP, non nell’LLM.
|
||||||
|
4. **Determinismo** — stessa input + config → stesso modello e stessi numeri. Niente hash random, niente ordine di hash map visibile nei finding, niente dipendenza dall’LLM.
|
||||||
|
5. **Confine Python grosso** — una chiamata: struct in → struct out. Vietato un round-trip per pad, per via, per “is_point_in_poly” dal loop Python.
|
||||||
|
|
||||||
|
Se manca anche uno solo di questi, il pezzo resta in Python.
|
||||||
|
|
||||||
|
## Sequenza obbligatoria (costituzione §§33–36)
|
||||||
|
|
||||||
|
```text
|
||||||
|
implementazione corretta in Python
|
||||||
|
↓
|
||||||
|
misura (wall + allocazioni, board reale: HubAudio / Emmaforo)
|
||||||
|
↓
|
||||||
|
profiling (il hot path ha un nome di funzione)
|
||||||
|
↓
|
||||||
|
scelta esplicita del pezzo in questo file
|
||||||
|
↓
|
||||||
|
Rust con confine grosso
|
||||||
|
↓
|
||||||
|
benchmark sulla stessa board
|
||||||
|
↓
|
||||||
|
accetta / rifiuta sui numeri e sulla semantica (via resta via)
|
||||||
|
```
|
||||||
|
|
||||||
|
Finché la riga “Pezzo scelto” sotto è vuota: **nessun rustup**.
|
||||||
|
|
||||||
|
## Cosa non va in Rust
|
||||||
|
|
||||||
|
| Area | Perché |
|
||||||
|
| --- | --- |
|
||||||
|
| Pipeline, job, SSE, FastAPI | orchestrazione |
|
||||||
|
| Estrazione datasheet, skills, LLM | I/O + modello |
|
||||||
|
| Review AI, normalize, dedup | non deterministico in autorità |
|
||||||
|
| Project CRUD, storage, report JSON | I/O |
|
||||||
|
| Frontend | altro runtime |
|
||||||
|
| Auth / JWT / users | fuori perimetro; non toccare |
|
||||||
|
| FEM, OpenEMS, ampacity IPC via, DRC | non è Periscope |
|
||||||
|
| ImpedenceFinder venduto | già un core chiuso; non riscrivere “per Rust” |
|
||||||
|
| Un check `PE-*` isolato | il check è regola + evidenza; il costo è il parser/geometria a monte |
|
||||||
|
|
||||||
|
## Candidati *solo dopo misura* (nessuno scelto)
|
||||||
|
|
||||||
|
Questi sono i posti dove un profilo *potrebbe* mostrare un hot path. Non sono un piano di porting.
|
||||||
|
|
||||||
|
- Parser `.kicad_pcb` (`parsers_kicad_pcb.py`) se il wall su HubAudio è il parse, non i check.
|
||||||
|
- Predicate geometriche usate da stub / zone / courtyard (track graph, point-in-poly) se il walk segmenti domina.
|
||||||
|
- Walk grafo net→pin su BOM grandi, se misurato.
|
||||||
|
|
||||||
|
Non candidati: `run_pcb_checks` come god-object Rust; “tutto il core”; Z inventata più veloce.
|
||||||
|
|
||||||
|
## Pezzo scelto
|
||||||
|
|
||||||
|
Nessuno.
|
||||||
|
|
||||||
|
Nessuna misura registrata in questo file. Quindi: niente toolchain Rust in questa macro-fase.
|
||||||
|
|
||||||
|
## Come registrare una scelta (futuro)
|
||||||
|
|
||||||
|
Quando una misura esiste, aggiungere qui:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Pezzo: <modulo / funzione>
|
||||||
|
Board: <HubAudio / Emmaforo / …>
|
||||||
|
Python wall: <s> alloc: <MB>
|
||||||
|
Hot path: <nome>
|
||||||
|
Ipotesi Rust: correttezza geometrica | memoria | parser | determinismo | confine
|
||||||
|
Benchmark dopo: <s> e test via≠pad ancora verdi
|
||||||
|
Decisione: accetta | rifiuta
|
||||||
|
```
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../../../../../src/frontend/src/app/(app)/library/library-editor.tsx
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../../../../../src/frontend/src/app/(app)/library/library-import.tsx
|
||||||
@@ -498,6 +498,56 @@ export async function fetchLibraryDatasheetUrl(mpn: string): Promise<string | nu
|
|||||||
return URL.createObjectURL(blob);
|
return URL.createObjectURL(blob);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function importLibraryDatasheet(mpn: string, file: File): Promise<{
|
||||||
|
mpn: string;
|
||||||
|
blob_key: string;
|
||||||
|
has_extraction: boolean;
|
||||||
|
has_model: boolean;
|
||||||
|
}> {
|
||||||
|
const body = new FormData();
|
||||||
|
body.append("mpn", mpn);
|
||||||
|
body.append("file", file);
|
||||||
|
const res = await authFetch(`${BASE}/api/library/datasheets`, { method: "POST", body });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = (await res.json().catch(() => ({ detail: "Import failed" }))) as {
|
||||||
|
detail?: string;
|
||||||
|
};
|
||||||
|
throw new Error(err.detail || "Failed to add datasheet to the library");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LibraryComponentType = "ic" | "passive" | "passive_part" | "simple";
|
||||||
|
|
||||||
|
export async function fetchLibraryComponent(
|
||||||
|
componentType: LibraryComponentType,
|
||||||
|
name: string,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const res = await authFetch(
|
||||||
|
`${BASE}/api/library/components/${componentType}/${encodeURIComponent(name)}`,
|
||||||
|
{ cache: "no-store" },
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error("Failed to load component");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateLibraryComponent(
|
||||||
|
componentType: LibraryComponentType,
|
||||||
|
name: string,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const res = await authFetch(
|
||||||
|
`${BASE}/api/library/components/${componentType}/${encodeURIComponent(name)}`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error("Failed to update component");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
// --- Pipeline ---
|
// --- Pipeline ---
|
||||||
|
|
||||||
export async function reprocessPipeline(
|
export async function reprocessPipeline(
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from backend.routers import (
|
|||||||
contact,
|
contact,
|
||||||
feedback,
|
feedback,
|
||||||
impedance,
|
impedance,
|
||||||
|
library,
|
||||||
pipeline,
|
pipeline,
|
||||||
projects,
|
projects,
|
||||||
reports,
|
reports,
|
||||||
@@ -163,6 +164,7 @@ async def _project_not_found_handler(request: Request, exc: ProjectNotFound):
|
|||||||
|
|
||||||
|
|
||||||
app.include_router(projects.router, prefix="/api")
|
app.include_router(projects.router, prefix="/api")
|
||||||
|
app.include_router(library.router, prefix="/api")
|
||||||
app.include_router(pipeline.router, prefix="/api")
|
app.include_router(pipeline.router, prefix="/api")
|
||||||
app.include_router(reports.router, prefix="/api")
|
app.include_router(reports.router, prefix="/api")
|
||||||
app.include_router(impedance.router, prefix="/api")
|
app.include_router(impedance.router, prefix="/api")
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""AI flags probable HF issues; deterministic checks investigate.
|
||||||
|
|
||||||
|
Extra path — does not replace ``run_pcb_checks`` / SI / HF. Not DRC.
|
||||||
|
Never invents Z, I, or millimetres. Missing net → silence (no N/A).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from backend.periscopex.hf_line_check import check_hf_lines
|
||||||
|
from backend.periscopex.models import DesignGraph, Finding, LayoutGraph
|
||||||
|
from backend.periscopex.pcb_net_match import kicad_nets_match
|
||||||
|
from backend.periscopex.si_check import check_si
|
||||||
|
|
||||||
|
SOURCE = "af_ai_hf"
|
||||||
|
RULE_Z_NO_EVIDENCE = "PE-AF-001"
|
||||||
|
|
||||||
|
ALLOWED_ISSUE_CLASSES = frozenset({
|
||||||
|
"stub",
|
||||||
|
"split_reference",
|
||||||
|
"unterminated",
|
||||||
|
"z0",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HfHypothesis:
|
||||||
|
"""Structured AI flag. ``why`` is INFERENCE, not FACT."""
|
||||||
|
|
||||||
|
net: str
|
||||||
|
issue_class: str
|
||||||
|
why: str
|
||||||
|
|
||||||
|
|
||||||
|
def _net_on_board(graph: DesignGraph, layout: LayoutGraph | None, net: str) -> bool:
|
||||||
|
if net in graph.nets:
|
||||||
|
return True
|
||||||
|
if layout is None:
|
||||||
|
return False
|
||||||
|
for seg in layout.segments:
|
||||||
|
if seg.net and kicad_nets_match(seg.net, net):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _has_z0_facts(layout: LayoutGraph | None) -> bool:
|
||||||
|
if layout is None or not layout.segments or layout.stackup is None:
|
||||||
|
return False
|
||||||
|
stack = layout.stackup
|
||||||
|
layers = getattr(stack, "copper_layers", None) or []
|
||||||
|
diel = getattr(stack, "dielectrics", None) or []
|
||||||
|
return bool(layers) and bool(diel)
|
||||||
|
|
||||||
|
|
||||||
|
def _z0_insufficient(net: str, why: str) -> Finding:
|
||||||
|
rec = (
|
||||||
|
"Provide stackup (copper + dielectric) and track geometry before Z0. "
|
||||||
|
"Do not assume 50 Ω or 90 Ω."
|
||||||
|
)
|
||||||
|
return Finding(
|
||||||
|
designator="layout",
|
||||||
|
mpn="",
|
||||||
|
aspect="si",
|
||||||
|
finding=f"Unverified: AI flagged Z0 on {net} — no stackup+track FACT.",
|
||||||
|
facts="stackup or HF track geometry missing.",
|
||||||
|
requirement="Closed-form Z0 needs board stackup and width on this net.",
|
||||||
|
inference=why,
|
||||||
|
why="AI hypothesis is not a measured ohm.",
|
||||||
|
status="INFO",
|
||||||
|
recommendation=rec,
|
||||||
|
action=rec,
|
||||||
|
source=SOURCE,
|
||||||
|
rule_id=RULE_Z_NO_EVIDENCE,
|
||||||
|
finding_class="INFO",
|
||||||
|
provenance="TYPICAL",
|
||||||
|
evidence_status="INSUFFICIENT",
|
||||||
|
net=net,
|
||||||
|
pins=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def investigate_hf_hypotheses(
|
||||||
|
graph: DesignGraph,
|
||||||
|
constraints_map: dict,
|
||||||
|
layout: LayoutGraph | None,
|
||||||
|
hypotheses: list[HfHypothesis],
|
||||||
|
impedance_nets: list[dict] | dict | None = None,
|
||||||
|
) -> list[Finding]:
|
||||||
|
"""Run existing HF/SI checks only on nets the AI flagged."""
|
||||||
|
out: list[Finding] = []
|
||||||
|
flagged: set[str] = set()
|
||||||
|
for hyp in hypotheses:
|
||||||
|
kind = (hyp.issue_class or "").strip().lower()
|
||||||
|
net = (hyp.net or "").strip()
|
||||||
|
if not net or kind not in ALLOWED_ISSUE_CLASSES:
|
||||||
|
continue
|
||||||
|
if not _net_on_board(graph, layout, net):
|
||||||
|
continue
|
||||||
|
if kind == "z0" and not _has_z0_facts(layout):
|
||||||
|
out.append(_z0_insufficient(net, hyp.why))
|
||||||
|
continue
|
||||||
|
flagged.add(net)
|
||||||
|
|
||||||
|
if not flagged or layout is None:
|
||||||
|
return out
|
||||||
|
|
||||||
|
probed = check_hf_lines(graph, constraints_map, layout)
|
||||||
|
probed.extend(check_si(graph, constraints_map, layout, impedance_nets))
|
||||||
|
for finding in probed:
|
||||||
|
net = finding.net or ""
|
||||||
|
if any(kicad_nets_match(net, flag) for flag in flagged if net):
|
||||||
|
out.append(finding)
|
||||||
|
return out
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""Standalone component library API — browse, import PDF, update cards.
|
||||||
|
|
||||||
|
No project id. Not admin/users. Analysis pipeline keeps using library_has_*.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
|
||||||
|
from fastapi.responses import JSONResponse, Response
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from backend.routers.deps import get_storage
|
||||||
|
from backend.services import library as lib_svc
|
||||||
|
from backend.services.library import LibraryError, MAX_DATASHEET_BYTES
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(tags=["library"])
|
||||||
|
|
||||||
|
|
||||||
|
class LibraryCheckRequest(BaseModel):
|
||||||
|
ic_mpns: list[str] = []
|
||||||
|
passive_mpns: list[str] = []
|
||||||
|
simple_mpns: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/library/check")
|
||||||
|
async def check_library(body: LibraryCheckRequest, request: Request):
|
||||||
|
storage = get_storage(request)
|
||||||
|
ics = [mpn for mpn in body.ic_mpns if lib_svc.library_has_extraction(storage, mpn)]
|
||||||
|
patterns = lib_svc.load_library_patterns(storage) if body.passive_mpns else []
|
||||||
|
passives: list[str] = []
|
||||||
|
if body.passive_mpns:
|
||||||
|
from backend.periscopex.resolve_passives import resolve_mpn
|
||||||
|
|
||||||
|
passives = [
|
||||||
|
mpn for mpn in body.passive_mpns
|
||||||
|
if resolve_mpn(mpn, patterns) is not None
|
||||||
|
or lib_svc.library_has_passive_model(storage, mpn) is not None
|
||||||
|
]
|
||||||
|
simples = [mpn for mpn in body.simple_mpns if lib_svc.library_has_model(storage, mpn)]
|
||||||
|
wanted = set(body.ic_mpns + body.passive_mpns + body.simple_mpns)
|
||||||
|
sheets = [
|
||||||
|
mpn for mpn in wanted
|
||||||
|
if lib_svc.library_has_datasheet(storage, mpn, patterns=patterns)
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"ic_resolved": ics,
|
||||||
|
"passive_resolved": passives,
|
||||||
|
"simple_resolved": simples,
|
||||||
|
"datasheets_available": sheets,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/library")
|
||||||
|
async def get_library(request: Request):
|
||||||
|
storage = get_storage(request)
|
||||||
|
return JSONResponse(
|
||||||
|
content=lib_svc.list_library_catalog(storage),
|
||||||
|
headers={"Cache-Control": "no-store"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/library/datasheet/{mpn:path}")
|
||||||
|
async def get_library_datasheet(mpn: str, request: Request):
|
||||||
|
storage = get_storage(request)
|
||||||
|
key = lib_svc.library_has_datasheet(storage, mpn)
|
||||||
|
if not key:
|
||||||
|
raise HTTPException(404, f"Datasheet not in library: {mpn}")
|
||||||
|
return Response(
|
||||||
|
content=storage.read_bytes(key),
|
||||||
|
media_type="application/pdf",
|
||||||
|
headers={"Content-Disposition": f'inline; filename="{mpn}.pdf"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/library/datasheets")
|
||||||
|
async def post_library_datasheet(
|
||||||
|
request: Request,
|
||||||
|
mpn: str = Form(...),
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
):
|
||||||
|
raw = await file.read()
|
||||||
|
if len(raw) > MAX_DATASHEET_BYTES:
|
||||||
|
raise HTTPException(
|
||||||
|
413, f"Datasheet exceeds {MAX_DATASHEET_BYTES // (1024 * 1024)} MB"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
row = lib_svc.import_datasheet(get_storage(request), mpn, raw)
|
||||||
|
except LibraryError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/library/components/{component_type}/{name:path}")
|
||||||
|
async def get_library_component(component_type: str, name: str, request: Request):
|
||||||
|
try:
|
||||||
|
data = lib_svc.get_component(get_storage(request), component_type, name)
|
||||||
|
except LibraryError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
if data is None:
|
||||||
|
raise HTTPException(404, f"Component not found: {name}")
|
||||||
|
return JSONResponse(content=data, headers={"Cache-Control": "no-store"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/library/components/{component_type}/{name:path}")
|
||||||
|
async def put_library_component(
|
||||||
|
component_type: str, name: str, request: Request,
|
||||||
|
):
|
||||||
|
payload = await request.json()
|
||||||
|
try:
|
||||||
|
body = lib_svc.update_component(
|
||||||
|
get_storage(request), component_type, name, payload,
|
||||||
|
)
|
||||||
|
except LibraryError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
return JSONResponse(content=body)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Project CRUD, library lookup, and file ingestion."""
|
"""Project CRUD and file ingestion."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -181,65 +181,6 @@ def _passive_hit_json(mpn: str, safe: str, model_data: dict, *, cached: bool, lc
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# --- Library ---
|
|
||||||
|
|
||||||
|
|
||||||
class LibraryCheckRequest(BaseModel):
|
|
||||||
ic_mpns: list[str] = []
|
|
||||||
passive_mpns: list[str] = []
|
|
||||||
simple_mpns: list[str] = []
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/library/check")
|
|
||||||
async def check_library(body: LibraryCheckRequest, request: Request):
|
|
||||||
storage = get_storage(request)
|
|
||||||
ics = [mpn for mpn in body.ic_mpns if proj_svc.library_has_extraction(storage, mpn)]
|
|
||||||
patterns = proj_svc.load_library_patterns(storage) if body.passive_mpns else []
|
|
||||||
passives: list[str] = []
|
|
||||||
if body.passive_mpns:
|
|
||||||
from backend.periscopex.resolve_passives import resolve_mpn
|
|
||||||
|
|
||||||
passives = [
|
|
||||||
mpn for mpn in body.passive_mpns
|
|
||||||
if resolve_mpn(mpn, patterns) is not None
|
|
||||||
or proj_svc.library_has_passive_model(storage, mpn) is not None
|
|
||||||
]
|
|
||||||
simples = [mpn for mpn in body.simple_mpns if proj_svc.library_has_model(storage, mpn)]
|
|
||||||
wanted = set(body.ic_mpns + body.passive_mpns + body.simple_mpns)
|
|
||||||
sheets = [
|
|
||||||
mpn for mpn in wanted
|
|
||||||
if proj_svc.library_has_datasheet(storage, mpn, patterns=patterns)
|
|
||||||
]
|
|
||||||
return {
|
|
||||||
"ic_resolved": ics,
|
|
||||||
"passive_resolved": passives,
|
|
||||||
"simple_resolved": simples,
|
|
||||||
"datasheets_available": sheets,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/library")
|
|
||||||
async def get_library(request: Request):
|
|
||||||
storage = get_storage(request)
|
|
||||||
return JSONResponse(
|
|
||||||
content=proj_svc.list_library_catalog(storage),
|
|
||||||
headers={"Cache-Control": "no-store"},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/library/datasheet/{mpn:path}")
|
|
||||||
async def get_library_datasheet(mpn: str, request: Request):
|
|
||||||
storage = get_storage(request)
|
|
||||||
key = proj_svc.library_has_datasheet(storage, mpn)
|
|
||||||
if not key:
|
|
||||||
raise HTTPException(404, f"Datasheet not in library: {mpn}")
|
|
||||||
return Response(
|
|
||||||
content=storage.read_bytes(key),
|
|
||||||
media_type="application/pdf",
|
|
||||||
headers={"Content-Disposition": f'inline; filename="{mpn}.pdf"'},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# --- CRUD ---
|
# --- CRUD ---
|
||||||
|
|
||||||
|
|
||||||
@@ -775,7 +716,7 @@ async def fetch_auto_datasheet(mpn: str, request: Request, lcsc: str | None = No
|
|||||||
get_storage(request), mpn, result.pdf_bytes, extra_mpns=result.alias_mpns,
|
get_storage(request), mpn, result.pdf_bytes, extra_mpns=result.alias_mpns,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
log.exception("library remember after auto-fetch failed for %s", mpn)
|
||||||
return Response(content=result.pdf_bytes, media_type="application/pdf", headers=headers)
|
return Response(content=result.pdf_bytes, media_type="application/pdf", headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
"""Shared component library — catalog, datasheet import, component JSON.
|
||||||
|
|
||||||
|
Not project CRUD. Analysis reads this store; it is not the only writer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from backend.periscopex.library_gate import should_promote_extraction
|
||||||
|
from backend.periscopex.utils import safe_mpn
|
||||||
|
from backend.services.datasheet_store import REF_PREFIX, resolve_datasheet, store_datasheet_bytes
|
||||||
|
from backend.services.storage import LocalStorageBackend, StorageBackend
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
MAX_DATASHEET_BYTES = 30 * 1024 * 1024
|
||||||
|
COMPONENT_TYPES = ("ic", "passive", "passive_part", "simple")
|
||||||
|
|
||||||
|
|
||||||
|
class LibraryError(ValueError):
|
||||||
|
"""Caller-visible library failure (bad MPN, not a PDF, empty pintable)."""
|
||||||
|
|
||||||
|
|
||||||
|
def _specs_param_count(specs: dict) -> int:
|
||||||
|
if not isinstance(specs, dict):
|
||||||
|
return 0
|
||||||
|
values = specs.get("values")
|
||||||
|
if isinstance(values, dict):
|
||||||
|
return sum(1 for v in values.values() if v not in (None, "", []))
|
||||||
|
skip = {"specs_type", "component_subtype"}
|
||||||
|
return sum(1 for k, v in specs.items() if k not in skip and v not in (None, "", []))
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_model_row(data: dict, key: str, *, row_type: str) -> dict:
|
||||||
|
mpn = data.get("mpn", "") or key.rsplit("/", 1)[-1].replace(".json", "")
|
||||||
|
specs = data.get("specs", {}) or {}
|
||||||
|
return {
|
||||||
|
"mpn": mpn,
|
||||||
|
"type": row_type,
|
||||||
|
"specs_type": specs.get("specs_type", ""),
|
||||||
|
"subtype": specs.get("component_subtype", ""),
|
||||||
|
"param_count": _specs_param_count(specs),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def component_key(component_type: str, name: str) -> str:
|
||||||
|
if component_type not in COMPONENT_TYPES:
|
||||||
|
raise LibraryError(f"Unknown component type: {component_type}")
|
||||||
|
safe = safe_mpn(name)
|
||||||
|
if not safe:
|
||||||
|
raise LibraryError("Invalid MPN")
|
||||||
|
if component_type == "ic":
|
||||||
|
return f"library/extracted/{safe}.json"
|
||||||
|
if component_type == "passive":
|
||||||
|
return f"library/patterns/{safe}.json"
|
||||||
|
if component_type == "passive_part":
|
||||||
|
return f"library/passives/{safe}.json"
|
||||||
|
return f"library/models/{safe}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def library_has_extraction(
|
||||||
|
storage: StorageBackend, mpn: str, min_version: str | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
key = f"library/extracted/{safe_mpn(mpn)}.json"
|
||||||
|
if not storage.exists(key):
|
||||||
|
return None
|
||||||
|
data = storage.read_json(key)
|
||||||
|
if not data.get("pintable"):
|
||||||
|
return None
|
||||||
|
if min_version:
|
||||||
|
from backend.services.admin_settings import version_is_stale
|
||||||
|
|
||||||
|
if version_is_stale(data.get("model_version", "0.0.0"), min_version):
|
||||||
|
return None
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def library_has_datasheet(
|
||||||
|
storage: StorageBackend, mpn: str, patterns: list | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
hit = resolve_datasheet(storage, mpn)
|
||||||
|
if hit:
|
||||||
|
return hit
|
||||||
|
legacy = f"library/datasheets/{safe_mpn(mpn)}.pdf"
|
||||||
|
if storage.exists(legacy):
|
||||||
|
return legacy
|
||||||
|
if patterns:
|
||||||
|
from backend.periscopex.resolve_passives import resolve_mpn
|
||||||
|
|
||||||
|
match = resolve_mpn(mpn, patterns)
|
||||||
|
if match is not None:
|
||||||
|
ds = match[0].datasheet_key
|
||||||
|
if ds and storage.exists(ds):
|
||||||
|
return ds
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def library_has_model(storage: StorageBackend, mpn: str) -> str | None:
|
||||||
|
key = f"library/models/{safe_mpn(mpn)}.json"
|
||||||
|
return key if storage.exists(key) else None
|
||||||
|
|
||||||
|
|
||||||
|
def library_has_passive_model(storage: StorageBackend, mpn: str) -> str | None:
|
||||||
|
safe = safe_mpn(mpn)
|
||||||
|
key = f"library/passives/{safe}.json"
|
||||||
|
if storage.exists(key):
|
||||||
|
return key
|
||||||
|
legacy = f"library/models/{safe}.json"
|
||||||
|
return legacy if storage.exists(legacy) else None
|
||||||
|
|
||||||
|
|
||||||
|
def save_to_library(
|
||||||
|
storage: StorageBackend, src_key: str, category: str, filename: str,
|
||||||
|
) -> str:
|
||||||
|
dst = f"library/{category}/{filename}"
|
||||||
|
storage.copy_object(src_key, dst)
|
||||||
|
return dst
|
||||||
|
|
||||||
|
|
||||||
|
def import_datasheet(
|
||||||
|
storage: StorageBackend,
|
||||||
|
mpn: str,
|
||||||
|
data: bytes,
|
||||||
|
extra_mpns: list[str] | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Store a PDF under the shared library. No project and no exam required."""
|
||||||
|
name = (mpn or "").strip()
|
||||||
|
if not name:
|
||||||
|
raise LibraryError("MPN is required")
|
||||||
|
if not data:
|
||||||
|
raise LibraryError("Empty file")
|
||||||
|
if len(data) > MAX_DATASHEET_BYTES:
|
||||||
|
raise LibraryError(
|
||||||
|
f"Datasheet exceeds {MAX_DATASHEET_BYTES // (1024 * 1024)} MB"
|
||||||
|
)
|
||||||
|
if not data.startswith(b"%PDF"):
|
||||||
|
raise LibraryError("File is not a PDF")
|
||||||
|
blob = store_datasheet_bytes(storage, data, name, extra_mpns=extra_mpns)
|
||||||
|
return {
|
||||||
|
"mpn": name,
|
||||||
|
"blob_key": blob,
|
||||||
|
"has_extraction": library_has_extraction(storage, name) is not None,
|
||||||
|
"has_model": (
|
||||||
|
library_has_model(storage, name) is not None
|
||||||
|
or library_has_passive_model(storage, name) is not None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def remember_datasheet(
|
||||||
|
storage: StorageBackend, mpn: str, data: bytes, extra_mpns: list[str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Exam/pipeline path: log and keep going if the shared store fails."""
|
||||||
|
try:
|
||||||
|
import_datasheet(storage, mpn, data, extra_mpns=extra_mpns)
|
||||||
|
except Exception:
|
||||||
|
log.exception("library datasheet store failed for %s", mpn)
|
||||||
|
|
||||||
|
|
||||||
|
def get_component(storage: StorageBackend, component_type: str, name: str) -> dict | None:
|
||||||
|
key = component_key(component_type, name)
|
||||||
|
if component_type == "passive_part" and not storage.exists(key):
|
||||||
|
key = f"library/models/{safe_mpn(name)}.json"
|
||||||
|
if not storage.exists(key):
|
||||||
|
return None
|
||||||
|
return storage.read_json(key)
|
||||||
|
|
||||||
|
|
||||||
|
def update_component(
|
||||||
|
storage: StorageBackend, component_type: str, name: str, payload: dict,
|
||||||
|
) -> dict:
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise LibraryError("Component body must be a JSON object")
|
||||||
|
body = dict(payload)
|
||||||
|
body.setdefault("mpn", name.strip())
|
||||||
|
if str(body.get("mpn") or "").strip() != name.strip():
|
||||||
|
raise LibraryError("Body MPN must match the path")
|
||||||
|
if component_type == "ic":
|
||||||
|
ok, reason = should_promote_extraction(body)
|
||||||
|
if not ok:
|
||||||
|
raise LibraryError(f"IC rejected: {reason}")
|
||||||
|
key = component_key(component_type, name)
|
||||||
|
storage.write_json(key, body)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def list_library_catalog(storage: StorageBackend) -> dict:
|
||||||
|
ics: list[dict] = []
|
||||||
|
seen_ic: set[str] = set()
|
||||||
|
for key in storage.list_prefix("library/extracted/"):
|
||||||
|
if not key.endswith(".json"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
data = storage.read_json(key)
|
||||||
|
mpn = data.get("mpn") or key.rsplit("/", 1)[-1].replace(".json", "")
|
||||||
|
if mpn in seen_ic:
|
||||||
|
continue
|
||||||
|
seen_ic.add(mpn)
|
||||||
|
ics.append({
|
||||||
|
"mpn": mpn,
|
||||||
|
"type": "ic",
|
||||||
|
"subtype": data.get("component_subtype", ""),
|
||||||
|
"pin_count": len(data.get("pintable", [])),
|
||||||
|
"has_ratings": bool(data.get("absolute_maximum_ratings")),
|
||||||
|
"has_datasheet": bool(resolve_datasheet(storage, mpn)),
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
log.exception("skip unreadable IC catalog key %s", key)
|
||||||
|
|
||||||
|
passives: list[dict] = []
|
||||||
|
seen_p: set[str] = set()
|
||||||
|
for key in storage.list_prefix("library/patterns/"):
|
||||||
|
if not key.endswith(".json"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
data = storage.read_json(key)
|
||||||
|
name = data.get("name") or key.rsplit("/", 1)[-1].replace(".json", "")
|
||||||
|
if name in seen_p:
|
||||||
|
continue
|
||||||
|
seen_p.add(name)
|
||||||
|
passives.append({
|
||||||
|
"mpn": name,
|
||||||
|
"type": "passive",
|
||||||
|
"subtype": data.get("component_type", ""),
|
||||||
|
"description": data.get("description", ""),
|
||||||
|
"regex": data.get("regex", ""),
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
log.exception("skip unreadable pattern catalog key %s", key)
|
||||||
|
|
||||||
|
simple_models: list[dict] = []
|
||||||
|
passive_parts: list[dict] = []
|
||||||
|
seen_m: set[str] = set()
|
||||||
|
for prefix, row_type, dest in (
|
||||||
|
("library/passives/", "passive_part", passive_parts),
|
||||||
|
("library/models/", "simple", simple_models),
|
||||||
|
):
|
||||||
|
for key in storage.list_prefix(prefix):
|
||||||
|
if not key.endswith(".json"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
data = storage.read_json(key)
|
||||||
|
row = _catalog_model_row(data, key, row_type=row_type)
|
||||||
|
if row["mpn"] in seen_m:
|
||||||
|
continue
|
||||||
|
seen_m.add(row["mpn"])
|
||||||
|
row["has_datasheet"] = bool(resolve_datasheet(storage, row["mpn"]))
|
||||||
|
dest.append(row)
|
||||||
|
except Exception:
|
||||||
|
log.exception("skip unreadable model catalog key %s", key)
|
||||||
|
|
||||||
|
datasheets: list[dict] = []
|
||||||
|
seen_d: set[str] = set()
|
||||||
|
for key in storage.list_prefix(REF_PREFIX):
|
||||||
|
if not key.endswith(".json"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
ref = storage.read_json(key)
|
||||||
|
mpn = ref.get("mpn") or key.rsplit("/", 1)[-1].replace(".json", "")
|
||||||
|
if mpn in seen_d:
|
||||||
|
continue
|
||||||
|
seen_d.add(mpn)
|
||||||
|
datasheets.append({
|
||||||
|
"mpn": mpn,
|
||||||
|
"hash": ref.get("hash"),
|
||||||
|
"has_extraction": mpn in seen_ic,
|
||||||
|
"has_model": mpn in seen_m,
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
log.exception("skip unreadable datasheet ref %s", key)
|
||||||
|
|
||||||
|
ics.sort(key=lambda r: r["mpn"].lower())
|
||||||
|
passives.sort(key=lambda r: r["mpn"].lower())
|
||||||
|
passive_parts.sort(key=lambda r: r["mpn"].lower())
|
||||||
|
simple_models.sort(key=lambda r: r["mpn"].lower())
|
||||||
|
datasheets.sort(key=lambda r: r["mpn"].lower())
|
||||||
|
return {
|
||||||
|
"ics": ics,
|
||||||
|
"passives": passives,
|
||||||
|
"passive_parts": passive_parts,
|
||||||
|
"simple": simple_models,
|
||||||
|
"datasheets": datasheets,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_library_patterns(storage: StorageBackend) -> list[str]:
|
||||||
|
return [k for k in storage.list_prefix("library/patterns/") if k.endswith(".json")]
|
||||||
|
|
||||||
|
|
||||||
|
def load_library_patterns(storage: StorageBackend):
|
||||||
|
from backend.periscopex.resolve_passives import load_patterns
|
||||||
|
|
||||||
|
if isinstance(storage, LocalStorageBackend):
|
||||||
|
d = storage._path("library/patterns")
|
||||||
|
if not d.is_dir():
|
||||||
|
return []
|
||||||
|
return load_patterns(str(d))
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
keys = list_library_patterns(storage)
|
||||||
|
if not keys:
|
||||||
|
return []
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
dest = Path(tmp) / "patterns"
|
||||||
|
dest.mkdir()
|
||||||
|
for key in keys:
|
||||||
|
storage.download_to_local(key, dest / key.rsplit("/", 1)[-1])
|
||||||
|
return load_patterns(str(dest))
|
||||||
@@ -15,6 +15,17 @@ from typing import Any
|
|||||||
from pydantic import AliasChoices, BaseModel, Field
|
from pydantic import AliasChoices, BaseModel, Field
|
||||||
|
|
||||||
from backend.periscopex.utils import natural_sort_key, safe_mpn
|
from backend.periscopex.utils import natural_sort_key, safe_mpn
|
||||||
|
from backend.services.library import (
|
||||||
|
library_has_datasheet,
|
||||||
|
library_has_extraction,
|
||||||
|
library_has_model,
|
||||||
|
library_has_passive_model,
|
||||||
|
list_library_catalog,
|
||||||
|
list_library_patterns,
|
||||||
|
load_library_patterns,
|
||||||
|
remember_datasheet,
|
||||||
|
save_to_library,
|
||||||
|
)
|
||||||
from backend.services.storage import StaleGeneration, StorageBackend
|
from backend.services.storage import StaleGeneration, StorageBackend
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -692,17 +703,6 @@ def save_pcb(
|
|||||||
return key
|
return key
|
||||||
|
|
||||||
|
|
||||||
def remember_datasheet(
|
|
||||||
storage: StorageBackend, mpn: str, data: bytes, extra_mpns: list[str] | None = None,
|
|
||||||
) -> None:
|
|
||||||
try:
|
|
||||||
from backend.services.datasheet_store import store_datasheet_bytes
|
|
||||||
|
|
||||||
store_datasheet_bytes(storage, data, mpn, extra_mpns=extra_mpns)
|
|
||||||
except Exception:
|
|
||||||
log.exception("library datasheet store failed for %s", mpn)
|
|
||||||
|
|
||||||
|
|
||||||
def save_datasheet(
|
def save_datasheet(
|
||||||
storage: StorageBackend, user_id: str, project_id: str, mpn: str, data: bytes,
|
storage: StorageBackend, user_id: str, project_id: str, mpn: str, data: bytes,
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -738,213 +738,3 @@ def get_datasheet_key(
|
|||||||
) -> str | None:
|
) -> str | None:
|
||||||
key = f"{_project_prefix(user_id, project_id)}/uploads/datasheets/{safe_mpn(mpn)}.pdf"
|
key = f"{_project_prefix(user_id, project_id)}/uploads/datasheets/{safe_mpn(mpn)}.pdf"
|
||||||
return key if storage.exists(key) else None
|
return key if storage.exists(key) else None
|
||||||
|
|
||||||
|
|
||||||
def library_has_extraction(
|
|
||||||
storage: StorageBackend, mpn: str, min_version: str | None = None,
|
|
||||||
) -> str | None:
|
|
||||||
key = f"library/extracted/{safe_mpn(mpn)}.json"
|
|
||||||
if not storage.exists(key):
|
|
||||||
return None
|
|
||||||
data = storage.read_json(key)
|
|
||||||
if not data.get("pintable"):
|
|
||||||
return None
|
|
||||||
if min_version:
|
|
||||||
from backend.services.admin_settings import version_is_stale
|
|
||||||
|
|
||||||
if version_is_stale(data.get("model_version", "0.0.0"), min_version):
|
|
||||||
return None
|
|
||||||
return key
|
|
||||||
|
|
||||||
|
|
||||||
def library_has_datasheet(
|
|
||||||
storage: StorageBackend, mpn: str, patterns: list | None = None,
|
|
||||||
) -> str | None:
|
|
||||||
from backend.services.datasheet_store import resolve_datasheet
|
|
||||||
|
|
||||||
hit = resolve_datasheet(storage, mpn)
|
|
||||||
if hit:
|
|
||||||
return hit
|
|
||||||
legacy = f"library/datasheets/{safe_mpn(mpn)}.pdf"
|
|
||||||
if storage.exists(legacy):
|
|
||||||
return legacy
|
|
||||||
if patterns:
|
|
||||||
from backend.periscopex.resolve_passives import resolve_mpn
|
|
||||||
|
|
||||||
match = resolve_mpn(mpn, patterns)
|
|
||||||
if match is not None:
|
|
||||||
ds = match[0].datasheet_key
|
|
||||||
if ds and storage.exists(ds):
|
|
||||||
return ds
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def library_has_model(storage: StorageBackend, mpn: str) -> str | None:
|
|
||||||
key = f"library/models/{safe_mpn(mpn)}.json"
|
|
||||||
return key if storage.exists(key) else None
|
|
||||||
|
|
||||||
|
|
||||||
def library_has_passive_model(storage: StorageBackend, mpn: str) -> str | None:
|
|
||||||
safe = safe_mpn(mpn)
|
|
||||||
key = f"library/passives/{safe}.json"
|
|
||||||
if storage.exists(key):
|
|
||||||
return key
|
|
||||||
legacy = f"library/models/{safe}.json"
|
|
||||||
return legacy if storage.exists(legacy) else None
|
|
||||||
|
|
||||||
|
|
||||||
def save_to_library(
|
|
||||||
storage: StorageBackend, src_key: str, category: str, filename: str,
|
|
||||||
) -> str:
|
|
||||||
dst = f"library/{category}/{filename}"
|
|
||||||
storage.copy_object(src_key, dst)
|
|
||||||
return dst
|
|
||||||
|
|
||||||
|
|
||||||
def _specs_param_count(specs: dict) -> int:
|
|
||||||
if not isinstance(specs, dict):
|
|
||||||
return 0
|
|
||||||
values = specs.get("values")
|
|
||||||
if isinstance(values, dict):
|
|
||||||
return sum(1 for v in values.values() if v not in (None, "", []))
|
|
||||||
skip = {"specs_type", "component_subtype"}
|
|
||||||
return sum(1 for k, v in specs.items() if k not in skip and v not in (None, "", []))
|
|
||||||
|
|
||||||
|
|
||||||
def _catalog_model_row(data: dict, key: str, *, row_type: str) -> dict:
|
|
||||||
mpn = data.get("mpn", "") or key.rsplit("/", 1)[-1].replace(".json", "")
|
|
||||||
specs = data.get("specs", {}) or {}
|
|
||||||
return {
|
|
||||||
"mpn": mpn,
|
|
||||||
"type": row_type,
|
|
||||||
"specs_type": specs.get("specs_type", ""),
|
|
||||||
"subtype": specs.get("component_subtype", ""),
|
|
||||||
"param_count": _specs_param_count(specs),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def list_library_catalog(storage: StorageBackend) -> dict:
|
|
||||||
from backend.services.datasheet_store import REF_PREFIX, resolve_datasheet
|
|
||||||
|
|
||||||
ics: list[dict] = []
|
|
||||||
seen_ic: set[str] = set()
|
|
||||||
for key in storage.list_prefix("library/extracted/"):
|
|
||||||
if not key.endswith(".json"):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
data = storage.read_json(key)
|
|
||||||
mpn = data.get("mpn") or key.rsplit("/", 1)[-1].replace(".json", "")
|
|
||||||
if mpn in seen_ic:
|
|
||||||
continue
|
|
||||||
seen_ic.add(mpn)
|
|
||||||
ics.append({
|
|
||||||
"mpn": mpn,
|
|
||||||
"type": "ic",
|
|
||||||
"subtype": data.get("component_subtype", ""),
|
|
||||||
"pin_count": len(data.get("pintable", [])),
|
|
||||||
"has_ratings": bool(data.get("absolute_maximum_ratings")),
|
|
||||||
"has_datasheet": bool(resolve_datasheet(storage, mpn)),
|
|
||||||
})
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
|
|
||||||
passives: list[dict] = []
|
|
||||||
seen_p: set[str] = set()
|
|
||||||
for key in storage.list_prefix("library/patterns/"):
|
|
||||||
if not key.endswith(".json"):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
data = storage.read_json(key)
|
|
||||||
name = data.get("name") or key.rsplit("/", 1)[-1].replace(".json", "")
|
|
||||||
if name in seen_p:
|
|
||||||
continue
|
|
||||||
seen_p.add(name)
|
|
||||||
passives.append({
|
|
||||||
"mpn": name,
|
|
||||||
"type": "passive",
|
|
||||||
"subtype": data.get("component_type", ""),
|
|
||||||
"description": data.get("description", ""),
|
|
||||||
"regex": data.get("regex", ""),
|
|
||||||
})
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
|
|
||||||
simple_models: list[dict] = []
|
|
||||||
passive_parts: list[dict] = []
|
|
||||||
seen_m: set[str] = set()
|
|
||||||
for prefix, row_type, dest in (
|
|
||||||
("library/passives/", "passive_part", passive_parts),
|
|
||||||
("library/models/", "simple", simple_models),
|
|
||||||
):
|
|
||||||
for key in storage.list_prefix(prefix):
|
|
||||||
if not key.endswith(".json"):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
data = storage.read_json(key)
|
|
||||||
row = _catalog_model_row(data, key, row_type=row_type)
|
|
||||||
if row["mpn"] in seen_m:
|
|
||||||
continue
|
|
||||||
seen_m.add(row["mpn"])
|
|
||||||
row["has_datasheet"] = bool(resolve_datasheet(storage, row["mpn"]))
|
|
||||||
dest.append(row)
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
|
|
||||||
datasheets: list[dict] = []
|
|
||||||
seen_d: set[str] = set()
|
|
||||||
for key in storage.list_prefix(REF_PREFIX):
|
|
||||||
if not key.endswith(".json"):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
ref = storage.read_json(key)
|
|
||||||
mpn = ref.get("mpn") or key.rsplit("/", 1)[-1].replace(".json", "")
|
|
||||||
if mpn in seen_d:
|
|
||||||
continue
|
|
||||||
seen_d.add(mpn)
|
|
||||||
datasheets.append({
|
|
||||||
"mpn": mpn,
|
|
||||||
"hash": ref.get("hash"),
|
|
||||||
"has_extraction": mpn in seen_ic,
|
|
||||||
"has_model": mpn in seen_m,
|
|
||||||
})
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
|
|
||||||
ics.sort(key=lambda r: r["mpn"].lower())
|
|
||||||
passives.sort(key=lambda r: r["mpn"].lower())
|
|
||||||
passive_parts.sort(key=lambda r: r["mpn"].lower())
|
|
||||||
simple_models.sort(key=lambda r: r["mpn"].lower())
|
|
||||||
datasheets.sort(key=lambda r: r["mpn"].lower())
|
|
||||||
return {
|
|
||||||
"ics": ics,
|
|
||||||
"passives": passives,
|
|
||||||
"passive_parts": passive_parts,
|
|
||||||
"simple": simple_models,
|
|
||||||
"datasheets": datasheets,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def list_library_patterns(storage: StorageBackend) -> list[str]:
|
|
||||||
return [k for k in storage.list_prefix("library/patterns/") if k.endswith(".json")]
|
|
||||||
|
|
||||||
|
|
||||||
def load_library_patterns(storage: StorageBackend):
|
|
||||||
from backend.periscopex.resolve_passives import load_patterns
|
|
||||||
from backend.services.storage import LocalStorageBackend
|
|
||||||
|
|
||||||
if isinstance(storage, LocalStorageBackend):
|
|
||||||
d = storage._path("library/patterns")
|
|
||||||
if not d.is_dir():
|
|
||||||
return []
|
|
||||||
return load_patterns(str(d))
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
keys = list_library_patterns(storage)
|
|
||||||
if not keys:
|
|
||||||
return []
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
dest = Path(tmp) / "patterns"
|
|
||||||
dest.mkdir()
|
|
||||||
for key in keys:
|
|
||||||
storage.download_to_local(key, dest / key.rsplit("/", 1)[-1])
|
|
||||||
return load_patterns(str(dest))
|
|
||||||
|
|||||||
@@ -2,6 +2,15 @@
|
|||||||
|
|
||||||
What's new in Periscope.
|
What's new in Periscope.
|
||||||
|
|
||||||
|
## 2.63.0 — 2026-09-21 — Library as its own product door
|
||||||
|
|
||||||
|
Standalone `/library` + `/api/library`: browse, add a datasheet PDF without an exam, update a component card. Datasheet prelim stays on the analysis path. Tests grouped datasheet → library → schematic → PCB → AF+AI (extra HF flags, then deterministic investigate). Rust criteria documented; no rustup. FEM out. DRC remains KiCad.
|
||||||
|
|
||||||
|
- [New] `POST /api/library/datasheets` stores a PDF by MPN with no project id.
|
||||||
|
- [New] `GET`/`PUT /api/library/components/{type}/{mpn}` for extracted cards.
|
||||||
|
- [New] Library page import form; empty library no longer says “run a review”.
|
||||||
|
- [New] `investigate_hf_hypotheses` (`PE-AF-001`) — AI Z0 flag without stackup is INSUFFICIENT, never 50/90 Ω. Existing `run_pcb_checks` unchanged.
|
||||||
|
|
||||||
## 2.62.1 — 2026-09-21 — PCB analysis remainder (Phase B)
|
## 2.62.1 — 2026-09-21 — PCB analysis remainder (Phase B)
|
||||||
|
|
||||||
Stackup vs fab spec, via current with datasheet I (no IPC via chart), ESD pad distance TVS–connector, USB-PD contract, PoE isolation voltage, antenna keepout/matching, PDN Z(f). Same pipeline and finding quality as 2.61.2 / 2.62.0. Skip if no evidence. Not DRC. Not FEM.
|
Stackup vs fab spec, via current with datasheet I (no IPC via chart), ESD pad distance TVS–connector, USB-PD contract, PoE isolation voltage, antenna keepout/matching, PDN Z(f). Same pipeline and finding quality as 2.61.2 / 2.62.0. Skip if no evidence. Not DRC. Not FEM.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "periscope-web",
|
"name": "periscope-web",
|
||||||
"version": "2.62.1",
|
"version": "2.63.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"sync-version": "node scripts/sync-version.mjs",
|
"sync-version": "node scripts/sync-version.mjs",
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
fetchLibraryComponent,
|
||||||
|
updateLibraryComponent,
|
||||||
|
type LibraryComponentType,
|
||||||
|
} from "@/lib/api";
|
||||||
|
|
||||||
|
export function LibraryEditor({
|
||||||
|
componentType,
|
||||||
|
name,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
componentType: LibraryComponentType;
|
||||||
|
name: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
}) {
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
fetchLibraryComponent(componentType, name)
|
||||||
|
.then((data) => {
|
||||||
|
if (!cancelled) setText(JSON.stringify(data, null, 2));
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to load");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [componentType, name]);
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setError(null);
|
||||||
|
let payload: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(text);
|
||||||
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||||
|
setError("Body must be a JSON object.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
payload = parsed as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
setError("Invalid JSON.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await updateLibraryComponent(componentType, name, payload);
|
||||||
|
onSaved();
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Save failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border bg-card p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="text-sm font-medium font-mono">{name}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-xs text-muted-foreground hover:underline"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||||
|
) : (
|
||||||
|
<textarea
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
className="w-full min-h-64 font-mono text-xs rounded-md border border-input bg-background p-2"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
<Button type="button" onClick={save} disabled={busy || loading}>
|
||||||
|
{busy ? "Saving…" : "Save"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { importLibraryDatasheet } from "@/lib/api";
|
||||||
|
|
||||||
|
export function LibraryImport({ onImported }: { onImported: () => void }) {
|
||||||
|
const [mpn, setMpn] = useState("");
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
if (!mpn.trim()) {
|
||||||
|
setError("MPN is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!file) {
|
||||||
|
setError("Choose a datasheet PDF.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await importLibraryDatasheet(mpn.trim(), file);
|
||||||
|
setMpn("");
|
||||||
|
setFile(null);
|
||||||
|
onImported();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Import failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={submit}
|
||||||
|
className="rounded-lg border border-border bg-card p-4 space-y-3"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Add a component from a datasheet</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
PDF + MPN. No project and no exam required. Pin tables still come
|
||||||
|
from the datasheet prelim in analysis.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="MPN"
|
||||||
|
value={mpn}
|
||||||
|
onChange={(e) => setMpn(e.target.value)}
|
||||||
|
className="sm:w-56 font-mono text-sm"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="file"
|
||||||
|
accept="application/pdf,.pdf"
|
||||||
|
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={busy}>
|
||||||
|
{busy ? "Saving…" : "Add to library"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,7 +17,10 @@ import {
|
|||||||
fetchLibrary,
|
fetchLibrary,
|
||||||
fetchLibraryDatasheetUrl,
|
fetchLibraryDatasheetUrl,
|
||||||
type LibraryCatalog,
|
type LibraryCatalog,
|
||||||
|
type LibraryComponentType,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
|
import { LibraryImport } from "./library-import";
|
||||||
|
import { LibraryEditor } from "./library-editor";
|
||||||
|
|
||||||
export default function LibraryPage() {
|
export default function LibraryPage() {
|
||||||
const [data, setData] = useState<LibraryCatalog | null>(null);
|
const [data, setData] = useState<LibraryCatalog | null>(null);
|
||||||
@@ -25,8 +28,20 @@ export default function LibraryPage() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [filter, setFilter] = useState("");
|
const [filter, setFilter] = useState("");
|
||||||
const [opening, setOpening] = useState<string | null>(null);
|
const [opening, setOpening] = useState<string | null>(null);
|
||||||
|
const [editor, setEditor] = useState<{
|
||||||
|
type: LibraryComponentType;
|
||||||
|
name: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
function reload() {
|
||||||
|
setError(null);
|
||||||
|
fetchLibrary()
|
||||||
|
.then(setData)
|
||||||
|
.catch((e) => setError(e instanceof Error ? e.message : "Failed to load library"));
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
fetchLibrary()
|
fetchLibrary()
|
||||||
.then(setData)
|
.then(setData)
|
||||||
.catch((e) => setError(e instanceof Error ? e.message : "Failed to load library"))
|
.catch((e) => setError(e instanceof Error ? e.message : "Failed to load library"))
|
||||||
@@ -128,20 +143,35 @@ export default function LibraryPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg font-semibold">Component library</h1>
|
<h1 className="text-lg font-semibold">Component library</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-1 max-w-2xl">
|
<p className="text-sm text-muted-foreground mt-1 max-w-2xl">
|
||||||
Datasheets, pin tables, and passive specs are stored once and reused
|
Shared datasheets and extracted cards. Add a PDF here without
|
||||||
on every later project. A second board with the same CH340E will not
|
starting an exam. Analysis still runs the datasheet prelim when a
|
||||||
re-download the PDF or re-extract the pin table.
|
board is reviewed.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<LibraryImport
|
||||||
|
onImported={() => {
|
||||||
|
reload();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{editor && (
|
||||||
|
<LibraryEditor
|
||||||
|
componentType={editor.type}
|
||||||
|
name={editor.name}
|
||||||
|
onClose={() => setEditor(null)}
|
||||||
|
onSaved={reload}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{empty ? (
|
{empty ? (
|
||||||
<div className="rounded-lg border border-border bg-card p-12 text-center space-y-3">
|
<div className="rounded-lg border border-border bg-card p-12 text-center space-y-3">
|
||||||
<Library className="h-8 w-8 text-muted-foreground mx-auto" />
|
<Library className="h-8 w-8 text-muted-foreground mx-auto" />
|
||||||
<p className="text-sm font-medium">Library is empty</p>
|
<p className="text-sm font-medium">Library is empty</p>
|
||||||
<p className="text-sm text-muted-foreground max-w-md mx-auto">
|
<p className="text-sm text-muted-foreground max-w-md mx-auto">
|
||||||
Create a project and run a review. Fetched datasheets land here
|
Drop a manufacturer PDF and its MPN above. Nothing is invented
|
||||||
immediately; pin tables and passive patterns arrive after the first
|
from an empty library — pin tables arrive when extraction has
|
||||||
extraction.
|
evidence.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -215,7 +245,15 @@ export default function LibraryPage() {
|
|||||||
<tbody className="divide-y divide-border">
|
<tbody className="divide-y divide-border">
|
||||||
{ics.map((ic) => (
|
{ics.map((ic) => (
|
||||||
<tr key={ic.mpn} className="hover:bg-muted/30">
|
<tr key={ic.mpn} className="hover:bg-muted/30">
|
||||||
<td className="px-3 py-2 font-mono text-xs">{ic.mpn}</td>
|
<td className="px-3 py-2 font-mono text-xs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hover:underline"
|
||||||
|
onClick={() => setEditor({ type: "ic", name: ic.mpn })}
|
||||||
|
>
|
||||||
|
{ic.mpn}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
{ic.subtype ? (
|
{ic.subtype ? (
|
||||||
<Badge variant="secondary">{ic.subtype}</Badge>
|
<Badge variant="secondary">{ic.subtype}</Badge>
|
||||||
@@ -272,7 +310,15 @@ export default function LibraryPage() {
|
|||||||
{passiveParts.map((p) => (
|
{passiveParts.map((p) => (
|
||||||
<tr key={p.mpn} className="hover:bg-muted/30">
|
<tr key={p.mpn} className="hover:bg-muted/30">
|
||||||
<td className="px-3 py-2 font-mono text-xs">
|
<td className="px-3 py-2 font-mono text-xs">
|
||||||
{p.mpn}
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hover:underline"
|
||||||
|
onClick={() =>
|
||||||
|
setEditor({ type: "passive_part", name: p.mpn })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{p.mpn}
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<Badge variant="secondary">
|
<Badge variant="secondary">
|
||||||
@@ -369,7 +415,15 @@ export default function LibraryPage() {
|
|||||||
<tbody className="divide-y divide-border">
|
<tbody className="divide-y divide-border">
|
||||||
{simple.map((s) => (
|
{simple.map((s) => (
|
||||||
<tr key={s.mpn} className="hover:bg-muted/30">
|
<tr key={s.mpn} className="hover:bg-muted/30">
|
||||||
<td className="px-3 py-2 font-mono text-xs">{s.mpn}</td>
|
<td className="px-3 py-2 font-mono text-xs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hover:underline"
|
||||||
|
onClick={() => setEditor({ type: "simple", name: s.mpn })}
|
||||||
|
>
|
||||||
|
{s.mpn}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<Badge variant="secondary">
|
<Badge variant="secondary">
|
||||||
{s.subtype || s.specs_type || "discrete"}
|
{s.subtype || s.specs_type || "discrete"}
|
||||||
|
|||||||
@@ -423,6 +423,48 @@ export async function fetchLibraryDatasheetUrl(mpn: string): Promise<string | nu
|
|||||||
return URL.createObjectURL(await res.blob());
|
return URL.createObjectURL(await res.blob());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function importLibraryDatasheet(mpn: string, file: File): Promise<{
|
||||||
|
mpn: string;
|
||||||
|
blob_key: string;
|
||||||
|
has_extraction: boolean;
|
||||||
|
has_model: boolean;
|
||||||
|
}> {
|
||||||
|
const body = new FormData();
|
||||||
|
body.append("mpn", mpn);
|
||||||
|
body.append("file", file);
|
||||||
|
const res = await send("/api/library/datasheets", { method: "POST", body });
|
||||||
|
return jsonOrThrow(res, "Failed to add datasheet to the library");
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LibraryComponentType = "ic" | "passive" | "passive_part" | "simple";
|
||||||
|
|
||||||
|
export async function fetchLibraryComponent(
|
||||||
|
componentType: LibraryComponentType,
|
||||||
|
name: string,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const res = await send(
|
||||||
|
`/api/library/components/${componentType}/${encodeURIComponent(name)}`,
|
||||||
|
{ cache: "no-store" },
|
||||||
|
);
|
||||||
|
return jsonOrThrow(res, "Failed to load component");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateLibraryComponent(
|
||||||
|
componentType: LibraryComponentType,
|
||||||
|
name: string,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const res = await send(
|
||||||
|
`/api/library/components/${componentType}/${encodeURIComponent(name)}`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return jsonOrThrow(res, "Failed to update component");
|
||||||
|
}
|
||||||
|
|
||||||
async function postPipeline(path: string, fallback: string, body?: unknown) {
|
async function postPipeline(path: string, fallback: string, body?: unknown) {
|
||||||
const res = await send(path, {
|
const res = await send(path, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
/** Stamped from content/changelog.md by scripts/sync-version.mjs. */
|
/** Stamped from content/changelog.md by scripts/sync-version.mjs. */
|
||||||
export const APP_VERSION = "2.62.1";
|
export const APP_VERSION = "2.63.0";
|
||||||
export const APP_VERSION_DATE = "2026-09-21";
|
export const APP_VERSION_DATE = "2026-09-21";
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
"""AF board + AI: hypotheses of probable HF issues, then deterministic checks.
|
||||||
|
|
||||||
|
Does not replace run_pcb_checks. Not DRC. Never invents Z.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from backend.periscopex.af_ai_hf import HfHypothesis, investigate_hf_hypotheses
|
||||||
|
from backend.periscopex.hf_line_check import check_hf_lines
|
||||||
|
from backend.periscopex.models import (
|
||||||
|
Component,
|
||||||
|
ComponentConstraints,
|
||||||
|
ComponentType,
|
||||||
|
DesignGraph,
|
||||||
|
LayoutFootprint,
|
||||||
|
LayoutGraph,
|
||||||
|
LayoutPad,
|
||||||
|
LayoutSegment,
|
||||||
|
LayoutStackup,
|
||||||
|
LayoutDielectric,
|
||||||
|
Net,
|
||||||
|
NetType,
|
||||||
|
Pin,
|
||||||
|
PinConnection,
|
||||||
|
)
|
||||||
|
from backend.periscopex.pcb_checks import run_pcb_checks
|
||||||
|
|
||||||
|
|
||||||
|
def _ic(ref: str, pins: dict[str, str], *, mpn: str = "PHY") -> Component:
|
||||||
|
return Component(
|
||||||
|
reference=ref, value=mpn, footprint="",
|
||||||
|
component_type=ComponentType.IC, mpn=mpn, pins=pins,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _net(name: str, *pairs: tuple[str, str]) -> Net:
|
||||||
|
return Net(
|
||||||
|
name=name, net_type=NetType.SIGNAL,
|
||||||
|
pins=[PinConnection(component_ref=r, pin_number=p) for r, p in pairs],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _usb_graph() -> DesignGraph:
|
||||||
|
return DesignGraph(
|
||||||
|
components={
|
||||||
|
"U1": _ic("U1", {"1": "USB_D+", "2": "USB_D-"}),
|
||||||
|
"J2": Component(
|
||||||
|
reference="J2", value="USB_C", footprint="",
|
||||||
|
component_type=ComponentType.CONNECTOR,
|
||||||
|
pins={"A6": "USB_D+", "A7": "USB_D-"},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
nets={
|
||||||
|
"USB_D+": _net("USB_D+", ("U1", "1"), ("J2", "A6")),
|
||||||
|
"USB_D-": _net("USB_D-", ("U1", "2"), ("J2", "A7")),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fp(ref: str, pads: list[LayoutPad], x: float = 0.0) -> LayoutFootprint:
|
||||||
|
return LayoutFootprint(reference=ref, footprint="P", x=x, y=0.0, pads=pads)
|
||||||
|
|
||||||
|
|
||||||
|
def _stub_layout() -> LayoutGraph:
|
||||||
|
return LayoutGraph(
|
||||||
|
footprints={
|
||||||
|
"U1": _fp("U1", [LayoutPad(number="1", x=0.0, y=0.0, net="USB_D+")]),
|
||||||
|
"J2": _fp("J2", [LayoutPad(number="A6", x=10.0, y=0.0, net="USB_D+")], x=10.0),
|
||||||
|
},
|
||||||
|
segments=[
|
||||||
|
LayoutSegment(start=(0, 0), end=(10, 0), width=0.2, layer="F.Cu", net="USB_D+"),
|
||||||
|
LayoutSegment(start=(10, 0), end=(10, 3), width=0.2, layer="F.Cu", net="USB_D+"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _stub_constraints() -> dict:
|
||||||
|
return {
|
||||||
|
"PHY": ComponentConstraints(
|
||||||
|
mpn="PHY", pintable=[Pin(number="1", name="D+")],
|
||||||
|
absolute_maximum_ratings=[], rules=[],
|
||||||
|
layout_rules=[{
|
||||||
|
"kind": "stub", "net_class": "usb2", "max_distance_mm": 1.0,
|
||||||
|
"note": "USB stub < 1 mm", "source_page": 12,
|
||||||
|
}],
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_ai_stub_flag_then_deterministic_pe_si_007():
|
||||||
|
graph = _usb_graph()
|
||||||
|
layout = _stub_layout()
|
||||||
|
cons = _stub_constraints()
|
||||||
|
flags = [HfHypothesis(net="USB_D+", issue_class="stub", why="looks long")]
|
||||||
|
out = investigate_hf_hypotheses(graph, cons, layout, flags)
|
||||||
|
codes = [f.rule_id for f in out]
|
||||||
|
assert "PE-SI-007" in codes
|
||||||
|
hit = next(f for f in out if f.rule_id == "PE-SI-007")
|
||||||
|
assert "FAIL" in hit.finding
|
||||||
|
assert hit.evidence_status == "SUFFICIENT"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ai_z0_flag_without_stackup_is_insufficient_no_ohm():
|
||||||
|
graph = _usb_graph()
|
||||||
|
layout = _stub_layout()
|
||||||
|
flags = [HfHypothesis(net="USB_D+", issue_class="z0", why="probably 90 ohm")]
|
||||||
|
out = investigate_hf_hypotheses(graph, {}, layout, flags)
|
||||||
|
assert len(out) == 1
|
||||||
|
f = out[0]
|
||||||
|
assert f.rule_id == "PE-AF-001"
|
||||||
|
assert f.evidence_status == "INSUFFICIENT"
|
||||||
|
assert "90" not in f.finding
|
||||||
|
assert "50" not in f.finding
|
||||||
|
assert "Ω" not in f.finding and "ohm" not in f.finding.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_ai_flag_on_missing_superspeed_net_is_silence():
|
||||||
|
graph = _usb_graph()
|
||||||
|
layout = _stub_layout()
|
||||||
|
flags = [HfHypothesis(net="USB3_SSTX_P", issue_class="stub", why="SS pair")]
|
||||||
|
out = investigate_hf_hypotheses(graph, _stub_constraints(), layout, flags)
|
||||||
|
assert out == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_pcb_checks_still_finds_stub_without_af_ai():
|
||||||
|
"""AF+AI is extra: the PCB check list must still emit PE-SI-007 alone."""
|
||||||
|
graph = _usb_graph()
|
||||||
|
layout = _stub_layout()
|
||||||
|
pcb = run_pcb_checks(graph, _stub_constraints(), layout)
|
||||||
|
assert any(f.rule_id == "PE-SI-007" for f in pcb)
|
||||||
|
hf = check_hf_lines(graph, _stub_constraints(), layout)
|
||||||
|
assert any(f.rule_id == "PE-SI-007" for f in hf)
|
||||||
|
|
||||||
|
|
||||||
|
def test_z0_with_stackup_does_not_invent_ohm_number():
|
||||||
|
graph = _usb_graph()
|
||||||
|
layout = _stub_layout()
|
||||||
|
layout.stackup = LayoutStackup(
|
||||||
|
copper_layers=["F.Cu", "B.Cu"],
|
||||||
|
dielectrics=[LayoutDielectric(name="core", er=4.5, height_mm=0.15)],
|
||||||
|
)
|
||||||
|
flags = [HfHypothesis(net="USB_D+", issue_class="z0", why="check Z0")]
|
||||||
|
out = investigate_hf_hypotheses(graph, {}, layout, flags)
|
||||||
|
for f in out:
|
||||||
|
assert "50 Ω" not in (f.finding or "")
|
||||||
|
assert "90 Ω" not in (f.finding or "")
|
||||||
@@ -130,3 +130,83 @@ def test_fetch_datasheet_persists_to_library(tmp_path, monkeypatch):
|
|||||||
catalog = client.get("/api/library").json()
|
catalog = client.get("/api/library").json()
|
||||||
assert any(d["mpn"] == "CH340E" for d in catalog["datasheets"])
|
assert any(d["mpn"] == "CH340E" for d in catalog["datasheets"])
|
||||||
assert client.get("/api/library/datasheet/CH340E").status_code == 200
|
assert client.get("/api/library/datasheet/CH340E").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_library_import_pdf_without_project(tmp_path):
|
||||||
|
client = _client(tmp_path)
|
||||||
|
before = client.get("/api/projects").json()
|
||||||
|
resp = client.post(
|
||||||
|
"/api/library/datasheets",
|
||||||
|
data={"mpn": "CH340E"},
|
||||||
|
files={"file": ("ch.pdf", PDF, "application/pdf")},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["mpn"] == "CH340E"
|
||||||
|
assert body["has_extraction"] is False
|
||||||
|
assert client.get("/api/projects").json() == before
|
||||||
|
catalog = client.get("/api/library").json()
|
||||||
|
assert any(d["mpn"] == "CH340E" for d in catalog["datasheets"])
|
||||||
|
pdf = client.get("/api/library/datasheet/CH340E")
|
||||||
|
assert pdf.status_code == 200
|
||||||
|
assert pdf.content.startswith(b"%PDF-")
|
||||||
|
|
||||||
|
|
||||||
|
def test_library_import_rejects_non_pdf(tmp_path):
|
||||||
|
client = _client(tmp_path)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/library/datasheets",
|
||||||
|
data={"mpn": "CH340E"},
|
||||||
|
files={"file": ("notes.txt", b"not a pdf", "text/plain")},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert client.get("/api/library").json()["datasheets"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_library_import_rejects_blank_mpn(tmp_path):
|
||||||
|
client = _client(tmp_path)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/library/datasheets",
|
||||||
|
data={"mpn": " "},
|
||||||
|
files={"file": ("ch.pdf", PDF, "application/pdf")},
|
||||||
|
)
|
||||||
|
assert resp.status_code in (400, 422)
|
||||||
|
|
||||||
|
|
||||||
|
def test_library_get_put_component(tmp_path):
|
||||||
|
client = _client(tmp_path)
|
||||||
|
from backend.main import app
|
||||||
|
|
||||||
|
storage = app.state.storage
|
||||||
|
storage.write_json(
|
||||||
|
"library/extracted/CH340E.json",
|
||||||
|
{
|
||||||
|
"mpn": "CH340E",
|
||||||
|
"component_subtype": "ic.interface.usb_uart_bridge",
|
||||||
|
"pintable": [
|
||||||
|
{"number": "1", "name": "VCC"},
|
||||||
|
{"number": "2", "name": "GND"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
got = client.get("/api/library/components/ic/CH340E")
|
||||||
|
assert got.status_code == 200
|
||||||
|
assert got.json()["component_subtype"] == "ic.interface.usb_uart_bridge"
|
||||||
|
|
||||||
|
payload = got.json()
|
||||||
|
payload["component_subtype"] = "ic.interface.usb_uart"
|
||||||
|
saved = client.put("/api/library/components/ic/CH340E", json=payload)
|
||||||
|
assert saved.status_code == 200
|
||||||
|
assert saved.json()["component_subtype"] == "ic.interface.usb_uart"
|
||||||
|
again = client.get("/api/library/components/ic/CH340E").json()
|
||||||
|
assert again["component_subtype"] == "ic.interface.usb_uart"
|
||||||
|
|
||||||
|
|
||||||
|
def test_library_put_rejects_empty_pintable(tmp_path):
|
||||||
|
client = _client(tmp_path)
|
||||||
|
resp = client.put(
|
||||||
|
"/api/library/components/ic/FAKEIC",
|
||||||
|
json={"mpn": "FAKEIC", "pintable": []},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert client.get("/api/library/components/ic/FAKEIC").status_code == 404
|
||||||
@@ -7,3 +7,4 @@ from pathlib import Path
|
|||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
SIMPLE_PROJECT = REPO_ROOT / "periscope" / "dependency" / "simple_project"
|
SIMPLE_PROJECT = REPO_ROOT / "periscope" / "dependency" / "simple_project"
|
||||||
TAXONOMY = REPO_ROOT / "periscope" / "src" / "taxonomy"
|
TAXONOMY = REPO_ROOT / "periscope" / "src" / "taxonomy"
|
||||||
|
FIXTURES = REPO_ROOT / "tests" / "fixtures"
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ def test_annotate_does_not_overwrite_existing_cad_fields():
|
|||||||
|
|
||||||
|
|
||||||
def test_kicad_sch_fields_include_uuid_and_child_sheet(tmp_path: Path):
|
def test_kicad_sch_fields_include_uuid_and_child_sheet(tmp_path: Path):
|
||||||
from tests.test_kicad_parser import _resistor, _sch
|
from tests.schematic.test_kicad_parser import _resistor, _sch
|
||||||
|
|
||||||
child = tmp_path / "analog.kicad_sch"
|
child = tmp_path / "analog.kicad_sch"
|
||||||
child.write_text(_sch(
|
child.write_text(_sch(
|
||||||
@@ -96,7 +96,7 @@ def test_run_pcb_checks_on_partial_layout_does_not_invent_tracks(tmp_path: Path)
|
|||||||
|
|
||||||
|
|
||||||
def test_partial_board_via_is_not_a_pad(tmp_path: Path):
|
def test_partial_board_via_is_not_a_pad(tmp_path: Path):
|
||||||
from tests.test_pcb_via_not_pad import _qfn24_pcb
|
from tests.pcb.test_pcb_via_not_pad import _qfn24_pcb
|
||||||
|
|
||||||
layout = parse_kicad_pcb(_qfn24_pcb(tmp_path, board_vias=[(80.0, 80.0, "GND", 1)]))
|
layout = parse_kicad_pcb(_qfn24_pcb(tmp_path, board_vias=[(80.0, 80.0, "GND", 1)]))
|
||||||
u1 = layout.footprints["U1"]
|
u1 = layout.footprints["U1"]
|
||||||
@@ -172,7 +172,7 @@ def test_missing_footprint_is_pe_lay_002():
|
|||||||
|
|
||||||
|
|
||||||
def test_run_pcb_checks_assigns_pcb_ids_and_recommendations():
|
def test_run_pcb_checks_assigns_pcb_ids_and_recommendations():
|
||||||
from tests.test_placement_check import _xtal_cons, _x1_c9_layout
|
from tests.pcb.test_placement_check import _xtal_cons, _x1_c9_layout
|
||||||
from backend.periscopex.models import LayoutSegment
|
from backend.periscopex.models import LayoutSegment
|
||||||
|
|
||||||
segs = [
|
segs = [
|
||||||
@@ -190,7 +190,7 @@ def test_run_pcb_checks_assigns_pcb_ids_and_recommendations():
|
|||||||
|
|
||||||
|
|
||||||
def test_close_decoupling_has_no_plc_001():
|
def test_close_decoupling_has_no_plc_001():
|
||||||
from tests.test_placement_check import _xtal_cons, _x1_c9_layout
|
from tests.pcb.test_placement_check import _xtal_cons, _x1_c9_layout
|
||||||
from backend.periscopex.models import LayoutSegment
|
from backend.periscopex.models import LayoutSegment
|
||||||
|
|
||||||
segs = [
|
segs = [
|
||||||
@@ -39,8 +39,9 @@ from backend.periscopex.pi_check import check_power_integrity
|
|||||||
from backend.periscopex.resolve_passives import decode_value
|
from backend.periscopex.resolve_passives import decode_value
|
||||||
from backend.services.passive_from_mpn import specs_from_mpn
|
from backend.services.passive_from_mpn import specs_from_mpn
|
||||||
from backend.services.passive_from_value import specs_from_bom_value
|
from backend.services.passive_from_value import specs_from_bom_value
|
||||||
|
from tests.paths import FIXTURES
|
||||||
|
|
||||||
_DRC = Path(__file__).parent / "fixtures" / "HubAudio-DRC.rpt"
|
_DRC = FIXTURES / "HubAudio-DRC.rpt"
|
||||||
_HUB_PCB = Path(
|
_HUB_PCB = Path(
|
||||||
"/Users/michelebigi/Development/HubAudio/hardware/kicad/HubAudio/HubAudio.kicad_pcb"
|
"/Users/michelebigi/Development/HubAudio/hardware/kicad/HubAudio/HubAudio.kicad_pcb"
|
||||||
)
|
)
|
||||||
@@ -348,7 +349,7 @@ def test_thinking_reasoning_content_echo_still_present():
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
src = (
|
src = (
|
||||||
Path(__file__).resolve().parents[1]
|
Path(__file__).resolve().parents[2]
|
||||||
/ "periscope" / "src" / "backend" / "services" / "llm" / "deepseek_provider.py"
|
/ "periscope" / "src" / "backend" / "services" / "llm" / "deepseek_provider.py"
|
||||||
)
|
)
|
||||||
text = src.read_text(encoding="utf-8")
|
text = src.read_text(encoding="utf-8")
|
||||||
@@ -15,7 +15,6 @@ from backend.periscopex.functional_groups import (
|
|||||||
from backend.periscopex.models import DesignGraph
|
from backend.periscopex.models import DesignGraph
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
SIMPLE = SIMPLE_PROJECT
|
SIMPLE = SIMPLE_PROJECT
|
||||||
|
|
||||||
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
"""HubAudio KiCad BOM: PNM column, grouped refs, no invented U13…U39 MPNs."""
|
"""HubAudio KiCad BOM: PNM column, grouped refs, no invented U13…U39 MPNs."""
|
||||||
|
|
||||||
from pathlib import Path
|
from tests.paths import FIXTURES
|
||||||
|
|
||||||
from backend.periscopex.parsers import ic_mpn_skip_reason, parse_bom
|
from backend.periscopex.parsers import ic_mpn_skip_reason, parse_bom
|
||||||
|
|
||||||
FIXTURE = Path(__file__).parent / "fixtures" / "hubaudio_kicad_bom.csv"
|
FIXTURE = FIXTURES / "hubaudio_kicad_bom.csv"
|
||||||
SKIPPED = ("U13", "U15", "U25", "U28", "U34", "U35", "U36", "U39")
|
SKIPPED = ("U13", "U15", "U25", "U28", "U34", "U35", "U36", "U39")
|
||||||
|
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ import backend.routers.admin as admin
|
|||||||
import backend.routers.contact as contact
|
import backend.routers.contact as contact
|
||||||
import backend.routers.deps as deps
|
import backend.routers.deps as deps
|
||||||
import backend.routers.feedback as feedback
|
import backend.routers.feedback as feedback
|
||||||
|
import backend.routers.library as library
|
||||||
import backend.routers.pipeline as pipeline
|
import backend.routers.pipeline as pipeline
|
||||||
import backend.routers.projects as projects
|
import backend.routers.projects as projects
|
||||||
import backend.routers.reports as reports
|
import backend.routers.reports as reports
|
||||||
@@ -27,6 +28,7 @@ def test_inherited_routers_are_src():
|
|||||||
(contact, "contact.py"),
|
(contact, "contact.py"),
|
||||||
(deps, "deps.py"),
|
(deps, "deps.py"),
|
||||||
(feedback, "feedback.py"),
|
(feedback, "feedback.py"),
|
||||||
|
(library, "library.py"),
|
||||||
(pipeline, "pipeline.py"),
|
(pipeline, "pipeline.py"),
|
||||||
(projects, "projects.py"),
|
(projects, "projects.py"),
|
||||||
(reports, "reports.py"),
|
(reports, "reports.py"),
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user