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:
2026-09-21 22:12:00 +02:00
parent 20733f0ec6
commit 4df04df5d4
101 changed files with 2060 additions and 302 deletions
+663
View File
@@ -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 lanalisi, 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** TVSconnettore (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 lIC.
- 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).
+138
View File
@@ -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 dallordine 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.** Lautorità deterministica è nei check; lLLM 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, limplementazione 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 lempty 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.
+37
View File
@@ -0,0 +1,37 @@
# Libreria componenti — porta prodotto
La libreria **non** vive dentro lesame. È una porta a sé: pagina `/library` + API `/api/library`. Lanalisi (preliminare datasheet, schematico, PCB) *usa* la libreria; non è lunico 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 nellanalisi, 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.
+83
View File
@@ -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 nellorchestrazione, non nellI/O HTTP, non nellLLM.
4. **Determinismo** — stessa input + config → stesso modello e stessi numeri. Niente hash random, niente ordine di hash map visibile nei finding, niente dipendenza dallLLM.
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 §§3336)
```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(
+2
View File
@@ -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
+119
View File
@@ -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)
+2 -61
View File
@@ -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)
+310
View File
@@ -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))
+11 -221
View File
@@ -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 TVSconnector, 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 TVSconnector, 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 -1
View File
@@ -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"}
+42
View File
@@ -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 -1
View File
@@ -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";
View File
+146
View File
@@ -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 "")
View File
View File
@@ -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
+1
View File
@@ -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"
View File
@@ -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
View File
@@ -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")
+2
View File
@@ -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