diff --git a/compass/__init__.py b/compass/__init__.py index 9af98f7e8..8ccb7a77a 100644 --- a/compass/__init__.py +++ b/compass/__init__.py @@ -8,6 +8,7 @@ # Temporarily import to register plugins # Can drop once plugins register themselves from .extraction import ( + COMPASSDataCentersExtractor, COMPASSGeoHeatPumpExtractor, COMPASSGeoElectricityExtractor, COMPASSNaturalGasPipelinesExtractor, diff --git a/compass/extraction/__init__.py b/compass/extraction/__init__.py index 6bcb6a4a6..fb0607b17 100644 --- a/compass/extraction/__init__.py +++ b/compass/extraction/__init__.py @@ -10,6 +10,7 @@ # Temporarily import to register plugins # Can drop once plugins register themselves +from .data_centers import COMPASSDataCentersExtractor from .ghp import COMPASSGeoHeatPumpExtractor from .geothermal_electricity import COMPASSGeoElectricityExtractor from .natural_gas import COMPASSNaturalGasExtractor diff --git a/compass/extraction/data_centers/__init__.py b/compass/extraction/data_centers/__init__.py new file mode 100644 index 000000000..a670aa41c --- /dev/null +++ b/compass/extraction/data_centers/__init__.py @@ -0,0 +1,12 @@ +"""COMPASS data centers plugin""" + +import importlib.resources + +from compass.plugin import create_schema_based_one_shot_extraction_plugin + + +COMPASSDataCentersExtractor = create_schema_based_one_shot_extraction_plugin( + importlib.resources.files("compass.extraction.data_centers") + / "plugin_config.yaml", + tech="data_centers", +) diff --git a/compass/extraction/data_centers/data_centers_schema.json b/compass/extraction/data_centers/data_centers_schema.json new file mode 100644 index 000000000..4389769f3 --- /dev/null +++ b/compass/extraction/data_centers/data_centers_schema.json @@ -0,0 +1,340 @@ +{ + "title": "Data Center Ordinance Extraction Schema", + "description": "One-shot extraction schema for local data center siting ordinances. Feature IDs mirror the controlled vocabulary used in the COMPASS data center ordinance research workbook, so extracted rows can be compared directly against the hand-coded reference set. The output is a sparse long-form table where each object is one row.", + "version": "2.0.0", + "type": "object", + "required": [ + "outputs", + "extraction_notes" + ], + "additionalProperties": false, + "properties": { + "outputs": { + "type": "array", + "description": "Sparse long-form extraction table. Emit a row only for a feature with an explicit, enacted requirement. Emit at most one row per (feature, data_center_type) pair; no two rows may share both. Where an ordinance sets a different standard for each class of data center, emit one row per data_center_type rather than collapsing them all.", + "items": { + "type": "object", + "required": [ + "feature", + "data_center_type", + "value", + "units", + "section", + "summary", + "ordinance_text", + "explanation" + ], + "additionalProperties": false, + "properties": { + "feature": { + "type": "string", + "description": "The ordinance feature being extracted. Must be one of the enumerated feature IDs. Do not invent aliases, prefixes, or synonym variants.", + "enum": [ + "setback - residential", + "setback - property line", + "setback - roads", + "setback - transit", + "setback - parks/natural areas", + "setback - data centers", + "setback for mechanical equipment", + "minimum lot size", + "maximum lot coverage", + "minimum vegetative cover", + "maximum height", + "maximum building size", + "data center floor-area share cap", + "parking", + "primary use districts", + "special use districts", + "accessory use districts", + "prohibited use districts", + "prohibitions/moratorium", + "noise", + "noise (low-frequency/tonal)", + "noise mitigation/acoustic barriers", + "vibration", + "light illumination limits", + "heat/thermal impact", + "buffer/landscaping", + "equipment screening/enclosure", + "fencing/security screening", + "backup generator operating restrictions", + "on-site power technology", + "decommissioning", + "cooling technology", + "water consumption limit", + "water reuse/reclaimed", + "definition" + ] + }, + "data_center_type": { + "type": [ + "string", + "null" + ], + "description": "The size tier of data center the provision governs, or 'Accessory Data Center'. Must be one of the enumerated values or null; do not write the jurisdiction's own class name here. Choose by the rules in $instructions.size_tiering. Use null when the requirement applies to data centers of any size. Any other distinction the ordinance draws is a condition, not a type. The jurisdiction's own class name and any conditions belong in summary, and the complete source wording in ordinance_text.", + "enum": [ + "Micro Data Center", + "Small Data Center", + "Medium Data Center", + "Large Data Center", + "Hyperscale Data Center", + "Accessory Data Center", + null + ] + }, + "value": { + "description": "The extracted requirement. For a numeric feature, a single number: the most restrictive value the ordinance sets for this feature and data center class, chosen by the rules in $instructions.numeric_prioritization. Never combine several numbers into one value; the other values belong in summary. Array of district names/codes for district features, boolean true where a requirement exists but is not numeric, or null where the feature is qualitative and the substance lives in the summary.", + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "units": { + "type": [ + "string", + "null" + ], + "description": "The unit of the value, written with the canonical vocabulary in $instructions.units, such as 'feet', 'acres', 'dBA', or 'percent'. Never convert the value into a different unit. Keep this to the unit alone: the measurement point, time period, and receiving use belong in summary. Null for district, qualitative, and other features that carry no unit." + }, + "section": { + "type": [ + "string", + "null" + ], + "description": "Pin cite to the governing provision, as specific as the source allows (chapter, article, section, and subsection). Null only when the source text carries no section identifier." + }, + "summary": { + "type": [ + "string", + "null" + ], + "description": "A short summary of the requirement in your own words. Where data_center_type is not null, start with the jurisdiction's own name for the class, exactly as the ordinance writes it, or with every such name where several local classes share the row. Retain all specifics and conditions, such as an exception for a power outage, a standard that steps up next to a residential district, or a sunset date. Where the ordinance sets several values for this feature, state which one value holds and list every other value with the condition it applies under, such as the time period, receiving use, zoning district, street class, or yard. The verbatim excerpt belongs in ordinance_text. For qualitative features, state the requirement in full." + }, + "ordinance_text": { + "type": "string", + "description": "The complete relevant text excerpt copied verbatim from the source document. Start with the full sentence corresponding to the extracted value and summary, without elision. Then include other passages supporting the same requirement, in source order, joining separate passages with an ellipsis ('...'). Preserve the facility class, conditions, and exceptions that govern this row. Every character outside the ellipses must appear exactly as written in the source. Do not paraphrase, normalize, or add commentary. Must be a non-empty string." + }, + "explanation": { + "type": "string", + "description": "Brief rationale explaining why the evidence in ordinance_text matches the selected feature and data_center_type, and how it supports the extracted value and units or the inclusion of a qualitative requirement. Must be a non-empty string." + } + } + } + }, + "extraction_notes": { + "type": "string", + "description": "One or two sentences on what was and was not extracted, and why. Always fill this in. When outputs is empty, state why nothing qualified (for example: the text has no data center provisions, only proposed or superseded provisions, or a moratorium whose end date has passed). When relevant text was deliberately left out of outputs, name it and give the reason." + } + }, + "$definitions": { + "setbacks": { + "setback - residential": { + "description": "Minimum separation between a data center and a residential or otherwise protected receptor, measured to a residential lot line, a residential zoning district boundary, or a property in residential use. Includes rules that name civic receptors such as schools, parks, churches, day care, or hospitals alongside residential property, and height-scaled setback increases triggered by adjacency to residential property. VALUE: the largest minimum distance. UNITS: a distance unit from $instructions.units, such as 'feet'. SUMMARY: name the receptors, any height-scaled increase with its trigger height, and the distances that apply to other receptors or districts. IGNORE: generic lot-line standards whose subject is the district boundary rather than a residential receptor, which belong under 'setback - property line'." + }, + "setback - property line": { + "description": "Minimum setback measured to a lot line, yard boundary, or zoning district boundary where the adjoining use is not residential or otherwise protected. Includes district front, side, and rear yard standards, and standards that step up where a lot abuts a residential district, since there the base requirement is generic and the residential case is a modifier rather than the subject. VALUE: the largest minimum distance across the front, side, rear, and per-district standards. UNITS: a distance unit from $instructions.units, such as 'feet'. SUMMARY: list the other yard and district distances, and any step-up next to a residential district, with their conditions. IGNORE: rules whose stated purpose is separation from a residential or protected receptor, which belong under 'setback - residential'." + }, + "setback - roads": { + "description": "Minimum setback measured to a roadway, street frontage, or right-of-way, including standards that scale by roadway classification. VALUE: the largest minimum distance. UNITS: a distance unit from $instructions.units, such as 'feet'. SUMMARY: list the distances for the other street classes. IGNORE: access, driveway spacing, and traffic study requirements that impose no separation distance." + }, + "setback - transit": { + "description": "Minimum separation between a data center and a high-capacity transit station, stop, or facility, including fixed guideway, bus rapid transit, commuter rail, and metro stations, measured to the transit facility. These provisions typically exist to reserve transit-served land for higher-employment uses rather than to protect the transit facility from impacts. VALUE: the largest minimum distance. UNITS: a distance unit from $instructions.units, such as 'feet'. IGNORE: transit-oriented development policy language that states no separation distance." + }, + "setback - parks/natural areas": { + "description": "Minimum separation from public parkland, trails, riparian corridors, natural areas, or designated open space. VALUE: the largest minimum distance. UNITS: a distance unit from $instructions.units, such as 'feet'. SUMMARY: note where the ordinance frames the distance as a maximum applied at the reviewer's discretion. IGNORE: environmental review, habitat study, and tree preservation requirements that state no separation distance." + }, + "setback - data centers": { + "description": "Minimum separation between a data center or data center campus and another data center. VALUE: the largest minimum distance. UNITS: a distance unit from $instructions.units, such as 'feet'. IGNORE: aggregation rules that define when adjacent buildings count as one campus without imposing a separation distance." + }, + "setback for mechanical equipment": { + "description": "Minimum setback applying to ancillary mechanical or electrical equipment such as generators, transformers, substations, chillers, cooling and ventilation equipment, or battery storage, rather than to the data center building. Code this feature only where the ordinance holds equipment to a different standard than the building; where one distance covers both, record the row under the applicable reference-object setback feature instead. VALUE: the largest minimum distance. UNITS: a distance unit from $instructions.units, such as 'feet'. SUMMARY: name the equipment and the receptor the distance is measured to. IGNORE: screening and enclosure requirements for the same equipment, which belong under 'equipment screening/enclosure'." + } + }, + "site_dimensions": { + "minimum lot size": { + "description": "Minimum lot or site area a data center must occupy, which may differ by zoning district. A floor on how much land the use requires. VALUE: the largest minimum lot area. UNITS: an area unit from $instructions.units, such as 'acres' or 'sq ft'. SUMMARY: list any minimum lot width and the areas that apply in other districts. IGNORE: ceilings on how much of that land may be built or paved, which belong under 'maximum lot coverage'." + }, + "maximum lot coverage": { + "description": "Ceiling on how much of a data center site may be built or paved, expressed as maximum building coverage, maximum lot coverage, maximum impervious coverage, or floor area ratio. VALUE: the lowest maximum coverage percentage, or the lowest floor area ratio where the ordinance states only a floor area ratio. UNITS: 'percent' or 'FAR'. SUMMARY: list any floor area ratio stated alongside a coverage percentage, impervious coverage limits, and the limits that apply in other districts. IGNORE: minimum land area requirements ('minimum lot size') and planting obligations ('minimum vegetative cover')." + }, + "minimum vegetative cover": { + "description": "Minimum share of a data center site that must be planted or retained in vegetation. A planting obligation rather than a limit on construction: it is not the complement of maximum coverage, since the remainder of a site may be pavement or hardstanding. VALUE: the largest minimum share. UNITS: 'percent'. SUMMARY: the basis the ordinance measures against, such as the lot. IGNORE: perimeter screening keyed to adjacent uses, which belongs under 'buffer/landscaping'." + }, + "maximum height": { + "description": "Maximum permitted height of a data center structure, expressed in feet, in stories, or in both, and often differing by zoning district. VALUE: the lowest maximum height, in feet where the ordinance states feet. UNITS: 'feet', or 'stories' where the ordinance states only stories. SUMMARY: list any story limit and the heights that apply in other districts. IGNORE: height-triggered setback increases, which belong on the applicable setback feature." + }, + "maximum building size": { + "description": "Maximum permitted data center building size, expressed as gross floor area or as square feet per floor. VALUE: the lowest maximum. UNITS: an area unit from $instructions.units, such as 'acres' or 'sq ft'. SUMMARY: state whether the cap is on gross floor area or per floor. IGNORE: floor area thresholds that merely trigger a permit pathway rather than capping building size, and share-of-project caps, which belong under 'data center floor-area share cap'." + }, + "data center floor-area share cap": { + "description": "Maximum data center share of total project floor area, including any occupancy-sequencing condition imposed on the project's other uses. VALUE: the lowest maximum share. UNITS: 'percent'. SUMMARY: any occupancy-sequencing condition. IGNORE: absolute floor area caps, which belong under 'maximum building size'." + }, + "parking": { + "description": "Off-street parking requirement for a data center use, expressed as a numerical ratio, minimum, maximum, or distance standard. The base differs by jurisdiction (total building area, office floor area only, or employee count), so ratios are not comparable across jurisdictions. VALUE: null; the substance belongs in the summary. UNITS: null. SUMMARY: record each ratio or count with the base it is measured against, and any threshold above which a different ratio applies. IGNORE: loading, stacking, and bicycle parking standards." + } + }, + "districts_and_permitting": { + "primary use districts": { + "description": "Zoning districts in which data centers are permitted as a primary use, that is without special conditions or discretionary approval. Following the COMPASS wind and solar convention, overlay districts are treated as primary use districts. VALUE: array of district names or codes, carrying the jurisdiction's own permit abbreviation alongside the district names where the ordinance uses one. UNITS: null. SUMMARY: any stated threshold that conditions the by-right pathway. IGNORE: districts where the use requires discretionary approval, which belong under 'special use districts'." + }, + "special use districts": { + "description": "Zoning districts in which data centers are permitted only with discretionary approval, whether by special use permit, special exception, conditional use, provisional use permit, special land use permit, development agreement, or an equivalent. VALUE: array of district names or codes, carrying the jurisdiction's own permit abbreviation alongside the district names. UNITS: null. SUMMARY: the approval pathway, naming the reviewing bodies where the ordinance does. IGNORE: generic permit administration and fee schedules not tied to data centers." + }, + "accessory use districts": { + "description": "Zoning districts in which a data center is permitted as an accessory or secondary use to a principal use rather than as a principal use in its own right. VALUE: array of district names or codes, carrying the jurisdiction's own permit abbreviation alongside the district names, or the ordinance's own phrasing where it permits the accessory use across all zones. UNITS: null. IGNORE: districts where the use is permitted as a principal use." + }, + "prohibited use districts": { + "description": "Zoning districts in which data centers are prohibited outright. VALUE: array of district names or codes, or the ordinance's own phrasing where a district list is stated as having no pathway. UNITS: null. IGNORE: jurisdiction-wide bans, moratoria, and use removals, which belong under 'prohibitions/moratorium'." + }, + "prohibitions/moratorium": { + "description": "Temporary moratorium, permanent prohibition, or removal of data centers as an allowed use, applying to the jurisdiction as a whole rather than to a list of districts. VALUE: null; the substance belongs in the summary. UNITS: null. SUMMARY: state the instrument, what it bars, and the effective and expiration dates where given, along with any carve-out such as a limited accessory allowance. IGNORE: district-level prohibitions, which belong under 'prohibited use districts'." + } + }, + "noise_and_nuisance": { + "noise": { + "description": "Maximum operational noise limits, usually expressed in dBA and often separated by time period, by receiving use, or by the zoning district of the receiving property. VALUE: the lowest numeric limit across all time periods, receiving uses, and districts, which is usually the nighttime limit at residential property. UNITS: 'dBA'. SUMMARY: list every other limit with its time period and receiving use or district, the measurement location, and any exception, such as one for a power outage or a stated allowance for a short duration. IGNORE: complaint-response protocol, monitoring workflow, and report submission timelines that state no numeric limit." + }, + "noise (low-frequency/tonal)": { + "description": "Numerical C-weighted, low-frequency, or tonal sound limits, which are distinct from conventional A-weighted dBA limits. VALUE: the lowest numeric limit across all time periods and receptors. UNITS: 'dB(C)'. SUMMARY: list the other limits with their time periods, and the measurement location and interval. IGNORE: A-weighted limits, which belong under 'noise'." + }, + "noise mitigation/acoustic barriers": { + "description": "Numerical noise attenuation standard for an enclosure or barrier serving mechanical equipment or generators, expressed as a maximum sound level measured at a stated distance. VALUE: the lowest maximum sound level. UNITS: 'dBA'. SUMMARY: the distance at which the level is measured and the equipment the standard covers. IGNORE: ambient and property line noise limits, which belong under the noise features, and non-numeric enclosure requirements, which belong under 'equipment screening/enclosure'." + }, + "vibration": { + "description": "Numerical vibration limit, duration threshold, or setback applying to vibration-producing equipment, measured at a property boundary or other receptor. VALUE: the lowest numeric vibration limit, or the largest setback distance where the ordinance states only a setback. UNITS: 'g peak', or 'feet' for a setback. SUMMARY: the receptor and any excluded duration. IGNORE: construction-period vibration provisions that do not govern operation." + }, + "light illumination limits": { + "description": "Maximum illumination received at a property line, lot line, zoning district boundary, or other receptor, expressed in foot-candles, lux, or an equivalent unit. Measures light crossing onto a neighboring parcel rather than glare as visual intrusion. VALUE: the lowest numeric maximum. UNITS: 'footcandles' or 'lux'. SUMMARY: the receptor the limit is measured at, and the limits that apply at other receiving districts. IGNORE: dark-sky standards such as uplight caps and correlated color temperature limits, which govern skyglow rather than illumination at a receptor." + }, + "heat/thermal impact": { + "description": "Numerical ambient temperature or thermal impact limit measured at a zoning boundary, property line, or other receptor. VALUE: the lowest numeric maximum, stated as an increase where the ordinance frames it that way. UNITS: '°F' or '°C'. SUMMARY: the receptor and whether the limit is an increase over ambient. IGNORE: waste heat recovery and heat reuse encouragement that imposes no limit." + } + }, + "site_design": { + "buffer/landscaping": { + "description": "Numerical buffer widths, berm or fence dimensions, planting quantities, spacing, composition, coverage, or installation requirements at the site perimeter. VALUE: the largest minimum buffer width, or null where the ordinance sets no buffer width. UNITS: a distance unit from $instructions.units, such as 'feet', or null with a null value. SUMMARY: list the other buffer widths with their conditions, and the berm or fence dimensions, planting quantities, spacing, composition, and coverage standards. IGNORE: requirements to screen mechanical equipment, which belong under 'equipment screening/enclosure', and site-wide planting shares, which belong under 'minimum vegetative cover'." + }, + "equipment screening/enclosure": { + "description": "Requirement to enclose or visually screen mechanical and electrical equipment serving a data center, including rooftop and ground-mounted units, generators, substations, loading and service areas, and refuse areas. VALUE: null; the substance belongs in the summary. UNITS: null. SUMMARY: what must be screened or enclosed and any numeric standard for wall or screen height, opacity, articulation, or clearance above the equipment. IGNORE: perimeter planting ('buffer/landscaping') and perimeter fencing ('fencing/security screening')." + }, + "fencing/security screening": { + "description": "Perimeter fencing or security screening requirement, including permitted and prohibited fence types and any height, opacity, or setback standard. VALUE: null; the substance belongs in the summary. UNITS: null. SUMMARY: the required or prohibited fence types and any height, stating whether it is a minimum or a maximum, opacity, or setback standard. IGNORE: screening of mechanical equipment, which belongs under 'equipment screening/enclosure', and security operations that impose no fencing requirement." + } + }, + "power_and_operations": { + "backup generator operating restrictions": { + "description": "Limits on when, and for how long, an emergency or backup generator may be operated: permitted testing and maintenance windows, maximum hours per day or month, maximum consecutive run time, and carve-outs for power outages or utility demand-response events. VALUE: null; the substance belongs in the summary. UNITS: null. SUMMARY: the permitted windows and the days and seasons they apply to, any hour limits, and the carve-outs. IGNORE: emissions standards, public notice and disclosure obligations, utility interconnection requirements, compliance logging, acoustic enclosure standards, and backup energy technology mandates, the last of which belongs under 'on-site power technology'." + }, + "on-site power technology": { + "description": "Required or prohibited on-site electricity generation and backup energy technology, including which generation types may serve as a primary, parallel, or routine source, which fuel types are permitted for backup service, any mandated battery or energy storage backup, and dispatch priority between sources. VALUE: null; the substance belongs in the summary. UNITS: null. IGNORE: limits on when generators may run, which belong under 'backup generator operating restrictions', and battery energy storage fire safety provisions." + }, + "decommissioning": { + "description": "End-of-life or decommissioning obligation for a data center, including equipment removal, disposal or recycling of servers and electronic waste, notification on cessation or abandonment, financial security, site restoration, and any timing requirement for beginning or completing decommissioning. VALUE: null; the substance belongs in the summary. UNITS: null. SUMMARY: record the triggering event and any stated deadline or security amount." + } + }, + "water_and_cooling": { + "cooling technology": { + "description": "Required or prohibited cooling technology, such as closed-loop, air-cooled, recycled-water, or no potable-water cooling. VALUE: null; the substance belongs in the summary. UNITS: null. SUMMARY: record which technologies are required or prohibited, any water use intensity stated for cooling with its units, and any circumstance under which potable water remains allowed, such as initial system charging or an emergency. IGNORE: volume ceilings ('water consumption limit') and reclaimed-source requirements ('water reuse/reclaimed')." + }, + "water consumption limit": { + "description": "Enforceable numerical ceiling on the volume of water a data center may use, expressed as a cap on annual or periodic consumption. VALUE: the lowest numeric ceiling. UNITS: a volume unit from $instructions.units, such as 'gallons', or 'percent' where the cap is expressed relative to a baseline allocation. SUMMARY: the period the cap covers and any baseline it is measured against. IGNORE: water conservation and management plans, which require a document rather than observance of a limit, and efficiency intensity standards, which set an intensity rather than a volume ceiling." + }, + "water reuse/reclaimed": { + "description": "Requirement or incentive to substitute reclaimed, recycled, or non-potable water for potable supply, including any minimum share of total water use that must be met from reclaimed sources. Governs the source of water rather than the volume. VALUE: the largest minimum share where stated, otherwise boolean true where reuse is required without a numeric share. UNITS: 'percent', or null with a boolean value. SUMMARY: the basis of the share and any administrative discretion to set a different share. IGNORE: volume ceilings, which belong under 'water consumption limit'." + } + }, + "terms": { + "definition": { + "description": "The jurisdiction's definition of a data center term, such as 'Data Center', 'Data Processing Center', or a size class like 'Hyperscale Data Center'. Emit one row per data_center_type: the size tier the defined term maps to, 'Accessory Data Center' for an accessory term, or null for the generic data center term. Where several defined terms map to the same data_center_type, put them in one row, each definition preceded by its term. Do not emit a row for a defined term that is neither a size tier, an accessory data center, nor the generic data center term, such as a data center campus. SUMMARY: each defined term as written, followed by what it is defined to mean, in full, including any size or power thresholds. VALUE: the definition text, verbatim. UNITS: null. SECTION: where the term is defined. IGNORE: definitions of other uses that merely mention data processing or computing in passing, such as a business office that lists data processing among its activities." + } + } + }, + "$instructions": { + "general": [ + "Extract only enacted local requirements that explicitly govern data centers, or that govern a use the ordinance defines as a data center, such as a data processing center or a computer server facility.", + "Also record each definition of a data center term as a 'definition' row, even though a definition is not itself a requirement.", + "Write summary in your own words. Put verbatim source passages in ordinance_text and explain how they support the row in explanation.", + "Never convert units. Preserve the jurisdiction's vocabulary except for the standard data_center_type values and the canonical unit names. Keep ordinance_text verbatim.", + "Extract only what the jurisdiction named in the system message has itself enacted. A meeting packet or staff report may reproduce other jurisdictions' regulations for comparison or attach text labelled as a draft or proposal: neither is a requirement of this jurisdiction, so emit no row from them.", + "Additions and strikethroughs alone do not make a text a draft. Unless it is labelled a draft or proposal, or says it was not adopted, extract an amending ordinance as amended: keep the added wording and leave out the struck wording.", + "Do not infer a requirement from related context, and do not emit a row for a feature the source does not address." + ], + "size_tiering": [ + "Follow the ordinance's own structure, feature by feature. Where it states a requirement separately for a size tier or for accessory data centers, emit a row with that data_center_type. Where it states a requirement for data centers generally, emit one row with null.", + "Do not split a generally stated requirement into a row per tier, and do not merge separately stated tier requirements into a null row, even when their wording is the same. One feature being stated per tier does not make every feature tiered: decide each feature from how the ordinance states that requirement.", + "data_center_type is a size tier, never the jurisdiction's wording. Where the ordinance's own tier name contains one of the words Micro, Small, Medium, Large, or Hyperscale, use the value with that same word: a 'Hyperscale Data Center' is always 'Hyperscale Data Center' and a 'Micro Data Center' is always 'Micro Data Center', however many tiers the ordinance has. Only a tier named with none of those words is mapped by meaning: 'Edge' -> 'Micro Data Center'; 'Minor' -> 'Small Data Center'; 'Mid-Size' -> 'Medium Data Center'; 'Large-Scale' or 'Major' -> 'Large Data Center'.", + "Map by the name the ordinance gives the tier, never by its size thresholds or its rank among the jurisdiction's tiers: one jurisdiction's 'Large' may be smaller than another's 'Medium', and a jurisdiction's largest tier is not 'Large Data Center' unless it is named so.", + "'Accessory Data Center' is the one type that is not a size: use it, whatever the size, for a data center that is accessory, ancillary, or incidental to another principal use.", + "Use null where the ordinance states a standard for data centers of any size. Generic terms such as 'Data Center', 'DATA CENTER', 'Data Processing Center', or 'Data Processing Facility' are not a size tier.", + "Any other distinction is a condition, not a type: whether the site is part of a unified development plan or a campus, its zoning district, its water or power use, and the like. Standards that differ by such a condition compete for the same row under $instructions.numeric_prioritization, with the other values and their conditions listed in summary.", + "Each (feature, data_center_type) pair appears in at most one row. A rule stated in more than one section is one row whose section cites each of them. Where two local tiers map to the same value, they share one row: for a numeric feature choose the most restrictive value across them, and in summary name each local tier with the value and conditions that apply to it." + ], + "setbacks": [ + "Setbacks are minimum separation distances. A setback row carries a single numeric distance and its unit.", + "Decide between 'setback - residential' and 'setback - property line' by what the rule is for: a rule whose subject is separation from a residential or protected receptor is residential, while a generic district yard standard that merely steps up next to a residential district is a property line setback.", + "Where a setback applies to ancillary mechanical or electrical equipment under a different standard than the building, record it under 'setback for mechanical equipment'; where one distance covers both, keep it on the reference-object feature." + ], + "numeric_prioritization": [ + "A numeric feature gets one row per data_center_type, and its value is a single number: the most restrictive value the ordinance sets for that feature and type.", + "Restrictiveness rules: setbacks, minimum lot size, minimum vegetative cover, buffer width, and minimum reclaimed water share -> choose the largest value; noise, low-frequency noise, enclosure sound levels, vibration, illumination, thermal increase, maximum height, maximum building size, maximum lot coverage, floor-area share cap, and water consumption ceilings -> choose the smallest value.", + "Values that differ by zoning district, time period, receiving use, street class, or yard compete for the same row. Do not emit a row per condition and do not combine the values into one string. List every value that was not selected in summary, with the condition it applies under.", + "Size tiers are the exception: standards for different data_center_type values stay in separate rows, per size_tiering, and the most restrictive value is chosen within each type.", + "Compare values across units if needed to find the most restrictive one, but record the selected value in its own unit. Never convert the reported value." + ], + "units": [ + "Write units with this canonical vocabulary: 'feet', 'miles', 'meters', 'acres', 'sq ft', 'stories', 'percent', 'FAR', 'dBA', 'dB(C)', 'footcandles', 'lux', 'g peak', '°F', '°C', 'gallons', 'acre-feet'. Spell the unit this way even where the ordinance abbreviates it: 'ft' becomes 'feet', 'ac' becomes 'acres', and 'dB(A)' becomes 'dBA'.", + "Choosing the canonical spelling is not a conversion. Never convert a value into a different unit: a distance stated in meters stays in meters.", + "Where the ordinance uses a unit not on the list, write its full name in lowercase.", + "Units hold the unit alone. Measurement points, time periods, receiving uses, approval pathways, and qualifiers such as 'maximum' or 'up to' belong in summary." + ], + "district_specific": [ + "For primary use districts, extract an array of district names or codes and treat overlay districts as primary use.", + "For special use districts, include districts where the use requires a special use permit, special exception, conditional use, or an equivalent discretionary approval.", + "For accessory use districts, include districts where a data center is allowed only as accessory or secondary to a principal use.", + "For prohibited use districts, include only districts where the use is barred outright and the prohibition is currently in effect.", + "Carry the jurisdiction's own permit abbreviation alongside the district names, and use district names and codes exactly as they appear in the ordinance." + ], + "null_handling": "Use sparse output. Emit rows only for features explicitly addressed by the source. Do not emit placeholder rows for missing features. Use a null value where a present feature is qualitative and the substance belongs in the summary." + }, + "$qa_checklist": [ + "All output rows use only the 35 allowed feature IDs.", + "Every row includes a non-empty ordinance_text excerpt and an explanation linking that evidence to the feature and facility class.", + "Every data_center_type is one of the standard values or null. No two rows share both feature and data_center_type. Generic requirements have null, tiered standards have one row per type, and each row with a non-null type names the jurisdiction's own class or classes at the start of summary. Distinctions other than size or accessory use appear only as conditions in summary.", + "Numeric setbacks, lot standards, and building size rows carry numeric values and non-null units.", + "Numeric rows carry a single number, the most restrictive value, with a plain unit, and summary lists the other values with their conditions.", + "Units use only the canonical vocabulary in $instructions.units and carry no measurement detail.", + "Noise rows carry the lowest explicit numeric limit, summary lists the other limits with their time periods and receiving uses, and no row is emitted from complaint-protocol language alone.", + "District rows are arrays of district names or codes, and jurisdiction-wide bans are recorded under 'prohibitions/moratorium' rather than 'prohibited use districts'.", + "Screening, buffer, and fencing rows are assigned by what the provision governs: equipment, site perimeter planting, or perimeter fencing.", + "Water rows are assigned by what the provision governs: technology, volume ceiling, or source substitution.", + "No row is emitted from a proposed, denied, or superseded provision, or from another jurisdiction's regulations reproduced in the text." + ], + "$qualitative_features": [ + "parking", + "prohibitions/moratorium", + "equipment screening/enclosure", + "fencing/security screening", + "backup generator operating restrictions", + "on-site power technology", + "decommissioning", + "cooling technology", + "definition" + ] +} diff --git a/compass/extraction/data_centers/plugin_config.yaml b/compass/extraction/data_centers/plugin_config.yaml new file mode 100644 index 000000000..e39d4da4b --- /dev/null +++ b/compass/extraction/data_centers/plugin_config.yaml @@ -0,0 +1,184 @@ +schema: ./data_centers_schema.json + +data_type_short_desc: data center ordinance + +cache_llm_generated_content: true + +query_templates: + - "{jurisdiction} data center ordinance" + # Recently adopted ordinances are often published only as a PDF on the + # jurisdiction's own site, before they are codified in a code library. + - "filetype:pdf {jurisdiction} data center ordinance" + - "site:{jurisdiction_website} data center" + - "{jurisdiction} zoning ordinance" + +website_keywords: + pdf: 92160 + "data center": 46080 + datacenter: 46080 + ordinance: 23040 + zoning: 11520 + setback: 5760 + noise: 5760 + "conditional use": 2880 + "special use": 2880 + hyperscale: 1440 + colocation: 1440 + generator: 1440 + "code of ordinances": 1440 + "municipal code": 1440 + ecode360: 1440 + municode: 1440 + "zoning ordinance": 1440 + "unified development ordinance": 720 + "ordinance adopted": 720 + "land use code": 720 + "text amendment": 720 + "land development": 720 + cooling: 360 + screening: 360 + county: 360 + municipal: 360 + chapter: 180 + section: 180 + planning: 180 + +heuristic_keywords: + # One mention of a data center term is enough for text to count, so + # every entry here must be specific to data centers. Generic zoning + # terms are left out: with a threshold of one they would let any + # zoning text through on their own. + min_matches: 1 + GOOD_TECH_KEYWORDS: + - datacenter + - hyperscale + - colocation + GOOD_TECH_PHRASES: + - data center + - data processing center + - data processing facility + - data center campus + - computer server + - server farm + - colocation facility + GOOD_TECH_ACRONYMS: + - PUE + - WUE + NOT_TECH_WORDS: + - community center + - recreation center + - civic center + - convention center + - shopping center + - medical center + - senior center + - visitor center + - town center + - detention center + - child care center + - call center + - distribution center + - service center + - data collection + - database + +collection_prompts: true + +extraction_system_prompt: |- + You are a legal scholar extracting structured data from local data + center siting ordinances. + + Extract ONLY the following features: + - setbacks from residential and protected receptors, property lines, + roads, transit facilities, parks and natural areas, other data + centers, and setbacks that apply to mechanical equipment under a + standard separate from the building + - minimum lot size, maximum lot coverage, minimum vegetative cover, + maximum height, maximum building size, data center share of project + floor area, and parking + - primary, special, accessory, and prohibited use districts, and + jurisdiction-wide prohibitions or moratoria + - noise limits, low-frequency and tonal noise limits, noise + attenuation standards for enclosures and barriers, vibration, light + illumination at a receptor, and thermal impact + - buffer and landscaping standards, equipment screening and + enclosure, and perimeter fencing + - backup generator operating restrictions, on-site power technology, + and decommissioning + - cooling technology, water consumption limits, and reclaimed water + requirements + + The jurisdiction is {jur_full_name}. Extract only requirements that + this jurisdiction has itself enacted. The text may be a meeting packet + or staff report rather than the code itself. Such a document often + attaches text labelled as a draft or proposal, or reproduces other + jurisdictions' regulations for comparison: emit no rows from either, + however detailed they are. If nothing else remains, return no rows + and say why in extraction_notes. + + Markup alone does not make a text a draft. Ordinances that amend a + code are commonly published showing additions and strikethroughs. + Unless the text is labelled a draft or proposal, or says it was not + adopted, extract it as amended: keep the added wording and leave out + the struck wording. + + Extract only enacted local requirements that explicitly govern data + centers, or that govern a use the ordinance defines as a data center, + such as a data processing center or a computer server facility. The + source may be a general zoning code, unified development ordinance, + municipal code, or a standalone adopted ordinance rather than a + technology-specific title. + + Data center ordinances frequently regulate by facility size class. + Follow the ordinance's own structure, feature by feature. Where it + states a requirement separately for a size tier, emit a row for that + data_center_type. Where it states a requirement for data centers + generally, emit one row with a null data_center_type. Do not split a + general requirement into a row per tier, and do not merge separately + stated tier requirements into one row. One feature being stated per + tier does not make every feature tiered. + + No two rows may share both feature and data_center_type: local + classes that map to the same type share a row, and a rule the text + states in more than one section is one row that cites each section. + Before answering, check the rows for a repeated feature and + data_center_type pair and merge any you find. + + For a numeric feature, value is a single number: the most restrictive + value the ordinance sets for that feature and facility class, such as + the largest setback or the lowest noise limit. Do not combine several + numbers into one value. List every other value in summary with the + condition it applies under, such as the time period, receiving use, + zoning district, street class, or yard. Keep units to the unit alone. + + data_center_type is a size tier from the schema's fixed list, or + Accessory Data Center. Map the jurisdiction's tier onto the list by + its name, such as Large Data Center for a Large-Scale or Major tier. + Set it to null when the requirement applies to data centers of any + size: generic labels such as Data Center, DATA CENTER, and Data + Processing Center are not size tiers. Any other distinction, such as + whether the site is part of a unified development plan, is a + condition rather than a type: take the most restrictive value and + list the others with their conditions in summary. Start summary with + the jurisdiction's own class name whenever data_center_type is not + null, and retain the complete source wording in ordinance_text. + + Do not extract text focused only on office, warehouse, distribution, + telecommunications, or utility uses unless the same clause directly + applies to data centers. Do not extract from proposed, denied, or + superseded provisions. + + For noise, ignore complaint-response and monitoring protocol details + unless the clause states an explicit numeric limit. + + Follow schema feature definitions literally. Keep the jurisdiction's + own vocabulary exactly as written, except for the data_center_type + values above. Write units with the schema's canonical unit names, such + as feet, acres, sq ft, percent, and dBA, even where the ordinance + abbreviates them. Do not convert values into a different unit. Do not + invent values. + + Write summary in your own words. Copy the supporting source passages + verbatim into ordinance_text, retaining the applicable facility class, + conditions, and exceptions. Use explanation to state how that evidence + supports the selected feature, facility class, value, and units. diff --git a/compass/plugin/one_shot/base.py b/compass/plugin/one_shot/base.py index 66ae2938b..6aa434ad2 100644 --- a/compass/plugin/one_shot/base.py +++ b/compass/plugin/one_shot/base.py @@ -40,6 +40,8 @@ logger = logging.getLogger(__name__) _SCHEMA_DIR = importlib.resources.files("compass.plugin.one_shot.schemas") +_MIN_MATCHES = "MIN_MATCHES" +"""Heuristic keywords entry holding the plugin's match threshold""" _QT_SEMAPHORE = Semaphore(1) _WK_SEMAPHORE = Semaphore(1) _HK_SEMAPHORE = Semaphore(1) @@ -99,7 +101,11 @@ def create_schema_based_one_shot_extraction_plugin(config, tech): lists used by the heuristic document filter. The dictionary must include ``not_tech_words``, ``good_tech_keywords``, ``good_tech_acronyms``, and - ``good_tech_phrases`` keys. Alternatively, this input can + ``good_tech_phrases`` keys. It may also set + ``min_matches``, the number of distinct entries that must + appear for text to pass (default ``2``); use ``1`` only + when every entry is specific to the technology, so one + mention is enough. Alternatively, this input can simply be ``True``, in which case the LLM will be used to generate heuristic keyword lists based on the schema input. If ``False``, ``None``, or not provided, a `NoOp` @@ -291,6 +297,9 @@ class SchemaBasedHeuristic(KeywordBasedHeuristic): GOOD_TECH_KEYWORDS = hk["GOOD_TECH_KEYWORDS"] GOOD_TECH_ACRONYMS = hk["GOOD_TECH_ACRONYMS"] GOOD_TECH_PHRASES = hk["GOOD_TECH_PHRASES"] + MIN_DEFAULT_MATCHES = hk.get( + _MIN_MATCHES, KeywordBasedHeuristic.MIN_DEFAULT_MATCHES + ) self.__class__.HEURISTIC_KEYWORDS = hk self.__class__.HEURISTIC = SchemaBasedHeuristic @@ -614,6 +623,7 @@ def _normalize_heuristic_keywords(raw): "GOOD_TECH_PHRASES", } + raw, min_matches = _split_min_matches(raw) normalized = _normalize_input_kw(raw, expected_keys) _verify_expected_kw_are_not_missing(normalized, expected_keys) @@ -621,9 +631,30 @@ def _normalize_heuristic_keywords(raw): _verify_min_number_of_kw_provided(normalized) _warn_if_not_enough_kw_provided(normalized) + if min_matches is not None: + normalized[_MIN_MATCHES] = min_matches return normalized +def _split_min_matches(raw): + """Separate the optional match threshold from the keyword lists""" + lists, min_matches = {}, None + for key, value in raw.items(): + if isinstance(key, str) and key.strip().upper() == _MIN_MATCHES: + min_matches = value + else: + lists[key] = value + if min_matches is None: + return lists, None + if isinstance(min_matches, bool) or not isinstance(min_matches, int): + msg = "Heuristic `min_matches` must be a positive integer." + raise COMPASSPluginConfigurationError(msg) + if min_matches < 1: + msg = "Heuristic `min_matches` must be a positive integer." + raise COMPASSPluginConfigurationError(msg) + return lists, min_matches + + def _normalize_input_kw(raw, expected_keys): """Normalize the input keyword dictionary""" normalized = {} diff --git a/compass/plugin/one_shot/components.py b/compass/plugin/one_shot/components.py index 84162441f..6918bb584 100644 --- a/compass/plugin/one_shot/components.py +++ b/compass/plugin/one_shot/components.py @@ -56,7 +56,11 @@ navigation links, cross-reference lists, or citation-only indexes as relevant \ on their own; mark them relevant only when they also include operative \ provisions, definitions, prohibitions, or other substantive regulatory text \ -that supports extraction. If relevant, summarize the specific matching \ +that supports extraction. The content must be about the specific technology \ +or use the schema targets; tangentially related material does not count, \ +such as a general definition or provision for some other use that merely \ +mentions a related activity in passing. \ +If relevant, summarize the specific matching \ content; if not, state why it does not meet the schema's requirements. \ Keep the response concise and consistent.\ """ @@ -516,8 +520,9 @@ async def parse(self, text, jurisdiction): ) data = extraction["outputs"] if not data: - logger.debug( - "LLM did not extract any relevant features from the text" + logger.info( + "LLM did not extract any relevant features from the text: %s", + extraction.get("extraction_notes") or "no explanation given", ) return None diff --git a/compass/plugin/ordinance.py b/compass/plugin/ordinance.py index 844c90a07..3accbeaae 100644 --- a/compass/plugin/ordinance.py +++ b/compass/plugin/ordinance.py @@ -60,6 +60,8 @@ "primary use districts", "special use districts", "accessory use districts", + # a definitions chapter defines terms but regulates nothing + "definition", } diff --git a/compass/utilities/costs.py b/compass/utilities/costs.py index 32e5c8c1c..384a63c68 100644 --- a/compass/utilities/costs.py +++ b/compass/utilities/costs.py @@ -25,6 +25,7 @@ "gpt-6-luna": {"prompt": 0.1, "response": 0.5}, "gpt-6-sol": {"prompt": 2, "response": 10}, "gpt-6-astra": {"prompt": 10, "response": 50}, + "gpt-6.1-sol": {"prompt": 2, "response": 10}, "text-embedding-ada-002": {"prompt": 0.10}, } """LLM Costs registry diff --git a/compass/utilities/finalize.py b/compass/utilities/finalize.py index 9571283b9..4e6daf421 100644 --- a/compass/utilities/finalize.py +++ b/compass/utilities/finalize.py @@ -324,10 +324,12 @@ def _normalize_qualitative_rows(db): they share a single output file and the ``quantitative`` flag is not published. - Their ``summary`` is also copied into ``value``. The schemas ask the - LLM for a summary and a null value on these rows, so filling the - value here means the model only ever writes the requirement once, - while readers still find it in the column they expect. + Their ``summary`` is also copied into ``value`` where the value is + empty. The schemas ask the LLM for a summary and a null value on + most of these rows, so filling the value here means the model only + ever writes the requirement once, while readers still find it in + the column they expect. A value the model did supply (such as the + verbatim text of a definition) is kept. """ qualitative = ~db["quantitative"] db.loc[qualitative, "units"] = QUALITATIVE_UNITS @@ -335,7 +337,12 @@ def _normalize_qualitative_rows(db): # a numeric-only "value" column is float-typed, so it has to be # widened before qualitative text can be written into it db["value"] = db["value"].astype(object) - db.loc[qualitative, "value"] = db.loc[qualitative, "summary"] + empty = db["value"].isna() | ~db["value"].astype(str).str.strip().astype( + bool + ) + db.loc[qualitative & empty, "value"] = db.loc[ + qualitative & empty, "summary" + ] return db diff --git a/tests/python/unit/extraction/test_extraction_data_centers_schema.py b/tests/python/unit/extraction/test_extraction_data_centers_schema.py new file mode 100644 index 000000000..75c42125d --- /dev/null +++ b/tests/python/unit/extraction/test_extraction_data_centers_schema.py @@ -0,0 +1,125 @@ +"""Checks for the data center extraction schema contract""" + +import json +from pathlib import Path + +import pytest + +import compass +from compass.extraction.data_centers import COMPASSDataCentersExtractor + + +SCHEMA_FP = ( + Path(compass.__file__).parent + / "extraction" + / "data_centers" + / "data_centers_schema.json" +) + + +def test_schema_asks_for_extraction_notes(): + """Every extraction explains itself, even when it finds nothing""" + schema = json.loads(SCHEMA_FP.read_text(encoding="utf-8")) + assert schema["required"] == ["outputs", "extraction_notes"] + notes = schema["properties"]["extraction_notes"] + assert notes["type"] == "string" + assert "outputs is empty" in notes["description"] + assert schema["additionalProperties"] is False + + +def test_schema_extracts_definitions_of_data_center_terms(): + """Definitions are a feature, described and marked qualitative""" + schema = json.loads(SCHEMA_FP.read_text(encoding="utf-8")) + features = schema["properties"]["outputs"]["items"]["properties"][ + "feature" + ]["enum"] + assert "definition" in features + described = { + name for group in schema["$definitions"].values() for name in group + } + assert described == set(features) + assert "definition" in schema["$qualitative_features"] + assert any( + f"{len(features)} allowed feature IDs" in item + for item in schema["$qa_checklist"] + ) + + +def test_data_center_type_is_a_fixed_list(): + """Types are size tiers plus accessory, from a closed list""" + schema = json.loads(SCHEMA_FP.read_text(encoding="utf-8")) + spec = schema["properties"]["outputs"]["items"]["properties"][ + "data_center_type" + ] + assert spec["enum"] == [ + "Micro Data Center", + "Small Data Center", + "Medium Data Center", + "Large Data Center", + "Hyperscale Data Center", + "Accessory Data Center", + None, + ] + rules = " ".join(schema["$instructions"]["size_tiering"]) + for name in spec["enum"][:-1]: + assert f"'{name}'" in rules, name + # local classes sharing a type merge, so a row's key stays unique + assert "at most one row" in rules + + +def test_numeric_features_ask_for_most_restrictive_value(): + """Numeric rows hold one value; the rest belong in the summary""" + schema = json.loads(SCHEMA_FP.read_text(encoding="utf-8")) + assert "compound" not in json.dumps(schema).casefold() + assert "numeric_prioritization" in schema["$instructions"] + + qualitative = set(schema["$qualitative_features"]) + definitions = { + name: spec["description"] + for group in schema["$definitions"].values() + for name, spec in group.items() + } + # a definition row's value is the definition text, not a requirement + definitions.pop("definition") + for name, description in definitions.items(): + value_rule = description.split("VALUE:")[1].split("UNITS:")[0] + if name in qualitative: + assert value_rule.strip().startswith("null"), name + elif not name.endswith("districts"): + assert "largest" in value_rule or "lowest" in value_rule, name + + +def test_units_use_canonical_vocabulary_without_detail(): + """Units name a canonical unit; any other detail goes in summary""" + schema = json.loads(SCHEMA_FP.read_text(encoding="utf-8")) + vocabulary = schema["$instructions"]["units"] + assert any("canonical vocabulary" in item for item in vocabulary) + + for group in schema["$definitions"].values(): + for name, spec in group.items(): + units_rule = spec["description"].split("UNITS:")[1] + units_rule = units_rule.split("SUMMARY:")[0].split("IGNORE:")[0] + assert "exactly as written" not in units_rule, name + if name.endswith("districts"): + assert units_rule.strip() == "null.", name + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("text", "passes"), + [ + ("Data centers shall maintain a 100-foot setback.", True), + ("A hyperscale facility requires a special exception.", True), + ("The zoning ordinance requires a conditional use permit.", False), + ("A backup generator is required for warehouses.", False), + ("UPS trucks deliver daily under the municipal code.", False), + ("The community center may host a data fair.", False), + ], +) +async def test_one_data_center_mention_passes_the_heuristic(text, passes): + """One specific term is enough; generic zoning terms never are""" + extractor = COMPASSDataCentersExtractor.__new__( + COMPASSDataCentersExtractor + ) + heuristic = await COMPASSDataCentersExtractor.get_heuristic(extractor) + assert heuristic.check(text) is passes diff --git a/tests/python/unit/plugin/test_plugin_one_shot_base.py b/tests/python/unit/plugin/test_plugin_one_shot_base.py index 161a73086..7255a7e28 100644 --- a/tests/python/unit/plugin/test_plugin_one_shot_base.py +++ b/tests/python/unit/plugin/test_plugin_one_shot_base.py @@ -8,6 +8,7 @@ import pandas as pd import pyjson5 +import compass.plugin.one_shot.base as one_shot_base from compass.plugin.one_shot.base import ( _out_cols_from_config, create_schema_based_one_shot_extraction_plugin, @@ -233,5 +234,36 @@ async def test_one_shot_plugin_loads_tiered_website_keywords(tmp_path): } +_KEYWORD_LISTS = { + "not_tech_words": ["community center"], + "good_tech_keywords": ["datacenter", "hyperscale"], + "good_tech_acronyms": ["PUE"], + "good_tech_phrases": ["data center", "server farm"], +} + + +def test_heuristic_keywords_accept_a_match_threshold(): + """A plugin may set how many distinct entries text must match""" + with pytest.warns(COMPASSPluginConfigurationWarning): + out = one_shot_base._normalize_heuristic_keywords( + {**_KEYWORD_LISTS, "min_matches": 1} + ) + default = one_shot_base._normalize_heuristic_keywords(_KEYWORD_LISTS) + assert out["MIN_MATCHES"] == 1 + assert out["GOOD_TECH_PHRASES"] == default["GOOD_TECH_PHRASES"] + assert "MIN_MATCHES" not in default + + +@pytest.mark.parametrize("value", [0, -1, 1.5, "2", True]) +def test_heuristic_match_threshold_must_be_a_positive_integer(value): + """An unusable threshold is rejected when the plugin is loaded""" + with pytest.raises( + one_shot_base.COMPASSPluginConfigurationError, match="min_matches" + ): + one_shot_base._normalize_heuristic_keywords( + {**_KEYWORD_LISTS, "min_matches": value} + ) + + if __name__ == "__main__": pytest.main(["-q", "--show-capture=all", Path(__file__), "-rapP"]) diff --git a/tests/python/unit/plugin/test_plugin_ordinances.py b/tests/python/unit/plugin/test_plugin_ordinances.py index 83d8c287c..b392c0afe 100644 --- a/tests/python/unit/plugin/test_plugin_ordinances.py +++ b/tests/python/unit/plugin/test_plugin_ordinances.py @@ -937,3 +937,18 @@ async def test_single_doc_extraction_runs_out_of_documents(tmp_path): "extract.doc.no_data", "no documents left", ) + + +def test_definitions_alone_do_not_make_an_ordinance(): + """A definitions chapter regulates nothing, so extraction moves on""" + data = pd.DataFrame( + { + "feature": ["definition", "definition", "noise"], + "value": ["A facility housing servers", "A large facility", None], + "summary": [None, None, None], + } + ) + count = OrdinanceExtractionPlugin.get_structured_data_row_count + assert count(data) == 0 + data.loc[2, "value"] = "55 dBA" + assert count(data) == 1 diff --git a/tests/python/unit/plugin/test_plugin_post_processing.py b/tests/python/unit/plugin/test_plugin_post_processing.py new file mode 100644 index 000000000..1fba6f3d5 --- /dev/null +++ b/tests/python/unit/plugin/test_plugin_post_processing.py @@ -0,0 +1,57 @@ +"""COMPASS plugin post-processing tests""" + +from pathlib import Path + +import pandas as pd +import pytest + +from compass.extraction.data_centers import COMPASSDataCentersExtractor +from compass.utilities.jurisdictions import Jurisdiction + + +def test_data_center_export_preserves_codes_and_types(tmp_path): + """FIPS codes and data center types survive the combined export""" + jurisdiction = Jurisdiction( + "city", "Arizona", subdivision_name="Mesa", code="0446000" + ) + source_dir = tmp_path / "jurisdiction_dbs" + source_dir.mkdir() + source_fp = source_dir / f"{jurisdiction.full_name} Ordinances.csv" + labels = [None, "Accessory Data Center", "Hyperscale Data Center"] + pd.DataFrame( + [ + { + "feature": "maximum height", + "data_center_type": label, + "value": 40, + "units": "feet", + "summary": "Maximum height is 40 feet.", + "ordinance_text": "Original wording", + "explanation": "The text states a height limit.", + "quantitative": True, + "year": 2026, + "source": "https://example.com/ordinance.pdf", + } + for label in labels + ] + ).to_csv(source_fp, index=False) + + COMPASSDataCentersExtractor.save_structured_data( + [{"ord_db_fp": source_fp, "jurisdiction": jurisdiction}], + tmp_path, + ) + + out = pd.read_csv( + tmp_path / "ordinances.csv", dtype=str, keep_default_na=False + ) + assert out["FIPS"].tolist() == ["0446000"] * len(labels) + assert out["data_center_type"].tolist() == [ + "", + "Accessory Data Center", + "Hyperscale Data Center", + ] + assert out["ordinance_text"].tolist() == ["Original wording"] * len(labels) + + +if __name__ == "__main__": + pytest.main(["-q", "--show-capture=all", Path(__file__), "-rapP"]) diff --git a/tests/python/unit/utilities/test_utilities_finalize.py b/tests/python/unit/utilities/test_utilities_finalize.py index 9ff6cd07d..45161c1f4 100644 --- a/tests/python/unit/utilities/test_utilities_finalize.py +++ b/tests/python/unit/utilities/test_utilities_finalize.py @@ -584,6 +584,36 @@ def _row(feature, quantitative, units): assert set(qual["feature"]) == {"Decommissioning", "Signage"} +def test_save_db_keeps_a_supplied_qualitative_value(tmp_path): + """A qualitative value the model supplied is not overwritten""" + out_cols = [col.name for col in COMPASSWindExtractor.OUTPUT_COLUMNS] + + def _row(feature, value, summary): + row = dict.fromkeys([*out_cols, "quantitative"]) + row.update( + feature=feature, + quantitative=False, + value=value, + summary=summary, + ordinance_text="quoted text", + ) + return row + + df = pd.DataFrame( + [ + _row("Glare", "Verbatim rule text.", "Paraphrase."), + _row("Color", " ", "Neutral colors."), + _row("Signage", None, "Warning signs only."), + ] + ) + finalize.save_db(df, tmp_path, COMPASSWindExtractor.OUTPUT_COLUMNS) + + out = pd.read_csv(tmp_path / "ordinances.csv").set_index("feature") + assert out.loc["Glare", "value"] == "Verbatim rule text." + assert out.loc["Color", "value"] == "Neutral colors." + assert out.loc["Signage", "value"] == "Warning signs only." + + def test_save_db_mirrors_summary_into_value_for_qualitative(tmp_path): """Copy summary into value on qualitative rows"""